mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ee6eb2f3 | ||
|
|
161d5d1e7f | ||
|
|
3cbd04b296 | ||
|
|
859f7f120c | ||
|
|
fea29f7318 | ||
|
|
f663b83ac8 | ||
|
|
ee99836285 | ||
|
|
2086c348a9 | ||
|
|
a8abf71bfe | ||
|
|
8dca670358 | ||
|
|
71556a51c5 | ||
|
|
2a92ea8862 | ||
|
|
681fc3cee5 | ||
|
|
916dd3ec26 | ||
|
|
692f7f3cde | ||
|
|
bf20f3d86e | ||
|
|
b7e720133d | ||
|
|
e914337e57 | ||
|
|
6364bac1f2 | ||
|
|
38a3e20427 | ||
|
|
334d75f2dd | ||
|
|
42eb783395 | ||
|
|
84b219957e | ||
|
|
f5c1ef36ce | ||
|
|
fae4fb0fed | ||
|
|
1d8729ec53 | ||
|
|
c6ef8a259f | ||
|
|
0efef5a789 | ||
|
|
db376c7504 | ||
|
|
8232812ac2 | ||
|
|
2ae06a8860 | ||
|
|
dc58a0701f | ||
|
|
3446280987 | ||
|
|
82bf1806ed | ||
|
|
47f0042bf0 | ||
|
|
58154063ed | ||
|
|
cc467889d0 | ||
|
|
469e5d2ed4 | ||
|
|
6ce301d7e0 | ||
|
|
8461de124f | ||
|
|
276f416ec9 | ||
|
|
583a844771 | ||
|
|
62fa437285 | ||
|
|
daab589c49 | ||
|
|
e18e9b25ce | ||
|
|
4cfb77dd44 | ||
|
|
7cab1e8782 | ||
|
|
079f37ec93 | ||
|
|
7ce97a616f | ||
|
|
946ed36af0 | ||
|
|
f139598526 | ||
|
|
40ddd3c066 | ||
|
|
3a66dc225d | ||
|
|
eadfd7a957 | ||
|
|
f739e0b372 | ||
|
|
23fb88e5fd | ||
|
|
49b9259452 | ||
|
|
4e26b6c92d | ||
|
|
215ce61b48 | ||
|
|
a48e06a28c | ||
|
|
8a59ab73a1 | ||
|
|
66d58288b4 | ||
|
|
be3f58f0a8 | ||
|
|
c299e403cc | ||
|
|
769c05e459 | ||
|
|
5ef3406068 | ||
|
|
95cbfb8c59 | ||
|
|
c17217875c | ||
|
|
981f7ac9b2 | ||
|
|
762db81252 | ||
|
|
79f6d87d7b | ||
|
|
c5d4356d6c | ||
|
|
c989dbf1b6 | ||
|
|
3cffa19319 | ||
|
|
2367f122a8 | ||
|
|
69a8e1657e | ||
|
|
987ce0ec4b | ||
|
|
03bf58671e | ||
|
|
cb6b810d6d | ||
|
|
408e6e5872 | ||
|
|
b3808add0f | ||
|
|
0b2e6efe28 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,9 @@ api.md
|
||||
usage.json
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
antigravity_usage.json
|
||||
codex_usage.json
|
||||
style.md
|
||||
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20aria-hidden%3D%22true%22%20role%3D%22img%22%20class%3D%22iconify%20iconify--logos%22%20width%3D%2231.88%22%20height%3D%2232%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20viewBox%3D%220%200%20256%20257%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb466%22%20x1%3D%22-.828%25%22%20x2%3D%2257.636%25%22%20y1%3D%227.652%25%22%20y2%3D%2278.411%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%2341D1FF%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23BD34FE%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3ClinearGradient%20id%3D%22IconifyId1813088fe1fbc01fb467%22%20x1%3D%2243.376%25%22%20x2%3D%2250.316%25%22%20y1%3D%222.242%25%22%20y2%3D%2289.03%25%22%3E%3Cstop%20offset%3D%220%25%22%20stop-color%3D%22%23FFEA83%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%228.333%25%22%20stop-color%3D%22%23FFDD35%22%3E%3C%2Fstop%3E%3Cstop%20offset%3D%22100%25%22%20stop-color%3D%22%23FFA800%22%3E%3C%2Fstop%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb466)%22%20d%3D%22M255.153%2037.938L134.897%20252.976c-2.483%204.44-8.862%204.466-11.382.048L.875%2037.958c-2.746-4.814%201.371-10.646%206.827-9.67l120.385%2021.517a6.537%206.537%200%200%200%202.322-.004l117.867-21.483c5.438-.991%209.574%204.796%206.877%209.62Z%22%3E%3C%2Fpath%3E%3Cpath%20fill%3D%22url(%23IconifyId1813088fe1fbc01fb467)%22%20d%3D%22M185.432.063L96.44%2017.501a3.268%203.268%200%200%200-2.634%203.014l-5.474%2092.456a3.268%203.268%200%200%200%203.997%203.378l24.777-5.718c2.318-.535%204.413%201.507%203.936%203.838l-7.361%2036.047c-.495%202.426%201.782%204.5%204.151%203.78l15.304-4.649c2.372-.72%204.652%201.36%204.15%203.788l-11.698%2056.621c-.732%203.542%203.979%205.473%205.943%202.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505%204.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E" />
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"gsap": "^3.14.2",
|
||||
"i18next": "^25.7.1",
|
||||
"react": "^19.2.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
@@ -3194,6 +3195,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
|
||||
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"dev": true,
|
||||
@@ -3773,9 +3780,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",
|
||||
@@ -3795,12 +3802,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"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -16,6 +16,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
"gsap": "^3.14.2",
|
||||
"i18next": "^25.7.1",
|
||||
"react": "^19.2.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
|
||||
33
src/App.tsx
33
src/App.tsx
@@ -1,16 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { SettingsPage } from '@/pages/SettingsPage';
|
||||
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
||||
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
||||
import { AuthFilesPage } from '@/pages/AuthFilesPage';
|
||||
import { OAuthPage } from '@/pages/OAuthPage';
|
||||
import { UsagePage } from '@/pages/UsagePage';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { LogsPage } from '@/pages/LogsPage';
|
||||
import { SystemPage } from '@/pages/SystemPage';
|
||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
@@ -43,6 +33,10 @@ function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 仅用于首屏同步 i18n 语言
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSplashReadyToFade(true);
|
||||
@@ -70,26 +64,13 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="api-keys" element={<ApiKeysPage />} />
|
||||
<Route path="ai-providers" element={<AiProvidersPage />} />
|
||||
<Route path="auth-files" element={<AuthFilesPage />} />
|
||||
<Route path="oauth" element={<OAuthPage />} />
|
||||
<Route path="usage" element={<UsagePage />} />
|
||||
<Route path="config" element={<ConfigPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
<Route path="system" element={<SystemPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
/>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
39
src/components/common/PageTransition.scss
Normal file
39
src/components/common/PageTransition.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
@use '@/styles/variables.scss' as *;
|
||||
|
||||
.page-transition {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&__layer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
||||
// During animation, exit layer uses absolute positioning
|
||||
&--exit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--animating &__layer {
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
// When both layers exist, current layer also needs positioning
|
||||
&--animating &__layer:not(&__layer--exit) {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
182
src/components/common/PageTransition.tsx
Normal file
182
src/components/common/PageTransition.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useLocation, type Location } from 'react-router-dom';
|
||||
import gsap from 'gsap';
|
||||
import './PageTransition.scss';
|
||||
|
||||
interface PageTransitionProps {
|
||||
render: (location: Location) => ReactNode;
|
||||
getRouteOrder?: (pathname: string) => number | null;
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const TRANSITION_DURATION = 0.5;
|
||||
const EXIT_DURATION = 0.45;
|
||||
const ENTER_DELAY = 0.08;
|
||||
|
||||
type LayerStatus = 'current' | 'exiting';
|
||||
|
||||
type Layer = {
|
||||
key: string;
|
||||
location: Location;
|
||||
status: LayerStatus;
|
||||
};
|
||||
|
||||
type TransitionDirection = 'forward' | 'backward';
|
||||
|
||||
export function PageTransition({
|
||||
render,
|
||||
getRouteOrder,
|
||||
scrollContainerRef,
|
||||
}: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||
const exitScrollOffsetRef = useRef(0);
|
||||
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
|
||||
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||
{
|
||||
key: location.key,
|
||||
location,
|
||||
status: 'current',
|
||||
},
|
||||
]);
|
||||
const currentLayerKey = layers[layers.length - 1]?.key ?? location.key;
|
||||
const currentLayerPathname = layers[layers.length - 1]?.location.pathname;
|
||||
|
||||
const resolveScrollContainer = useCallback(() => {
|
||||
if (scrollContainerRef?.current) return scrollContainerRef.current;
|
||||
if (typeof document === 'undefined') return null;
|
||||
return document.scrollingElement as HTMLElement | null;
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
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) => {
|
||||
if (!getRouteOrder || !pathname) return null;
|
||||
const index = getRouteOrder(pathname);
|
||||
return typeof index === 'number' && index >= 0 ? index : null;
|
||||
};
|
||||
const fromIndex = resolveOrderIndex(currentLayerPathname);
|
||||
const toIndex = resolveOrderIndex(location.pathname);
|
||||
const nextDirection: TransitionDirection =
|
||||
fromIndex === null || toIndex === null || fromIndex === toIndex
|
||||
? 'forward'
|
||||
: toIndex > fromIndex
|
||||
? 'forward'
|
||||
: 'backward';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isAnimating,
|
||||
location,
|
||||
currentLayerKey,
|
||||
currentLayerPathname,
|
||||
getRouteOrder,
|
||||
resolveScrollContainer,
|
||||
]);
|
||||
|
||||
// Run GSAP animation when animating starts
|
||||
useLayoutEffect(() => {
|
||||
if (!isAnimating) return;
|
||||
|
||||
if (!currentLayerRef.current) return;
|
||||
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const scrollOffset = exitScrollOffsetRef.current;
|
||||
if (scrollContainer && scrollOffset > 0) {
|
||||
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
const containerHeight = scrollContainer?.clientHeight ?? 0;
|
||||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
|
||||
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
|
||||
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
|
||||
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
|
||||
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
||||
|
||||
const tl = gsap.timeline({
|
||||
onComplete: () => {
|
||||
setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting'));
|
||||
setIsAnimating(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Exit animation: fly out to top (slow-to-fast)
|
||||
if (exitingLayerRef.current) {
|
||||
gsap.set(exitingLayerRef.current, { y: exitBaseY });
|
||||
tl.fromTo(
|
||||
exitingLayerRef.current,
|
||||
{ y: exitBaseY, opacity: 1 },
|
||||
{
|
||||
y: exitBaseY + exitToY,
|
||||
opacity: 0,
|
||||
duration: EXIT_DURATION,
|
||||
ease: 'power2.in', // fast finish to clear screen
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Enter animation: slide in from bottom (slow-to-fast)
|
||||
tl.fromTo(
|
||||
currentLayerRef.current,
|
||||
{ y: enterFromY, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: TRANSITION_DURATION,
|
||||
ease: 'power2.out', // smooth settle
|
||||
clearProps: 'transform,opacity',
|
||||
force3D: true,
|
||||
},
|
||||
ENTER_DELAY
|
||||
);
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
|
||||
};
|
||||
}, [isAnimating, transitionDirection, resolveScrollContainer]);
|
||||
|
||||
return (
|
||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||
{layers.map((layer) => (
|
||||
<div
|
||||
key={layer.key}
|
||||
className={`page-transition__layer${
|
||||
layer.status === 'exiting' ? ' page-transition__layer--exit' : ''
|
||||
}`}
|
||||
ref={layer.status === 'exiting' ? exitingLayerRef : currentLayerRef}
|
||||
>
|
||||
{render(layer.location)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { PageTransition } from '@/components/common/PageTransition';
|
||||
import { MainRoutes } from '@/router/MainRoutes';
|
||||
import {
|
||||
IconBot,
|
||||
IconChartLine,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconSlidersHorizontal,
|
||||
IconTimer,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import {
|
||||
@@ -33,6 +36,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} />,
|
||||
@@ -41,6 +45,7 @@ const sidebarIcons: Record<string, ReactNode> = {
|
||||
aiProviders: <IconBot size={18} />,
|
||||
authFiles: <IconFileText size={18} />,
|
||||
oauth: <IconShield size={18} />,
|
||||
quota: <IconTimer size={18} />,
|
||||
usage: <IconChartLine size={18} />,
|
||||
config: <IconSettings size={18} />,
|
||||
logs: <IconScrollText size={18} />,
|
||||
@@ -173,6 +178,7 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
|
||||
export function MainLayout() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const location = useLocation();
|
||||
|
||||
const apiBase = useAuthStore((state) => state.apiBase);
|
||||
const serverVersion = useAuthStore((state) => state.serverVersion);
|
||||
@@ -197,6 +203,7 @@ export function MainLayout() {
|
||||
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const headerRef = useRef<HTMLElement | null>(null);
|
||||
const versionTapCount = useRef(0);
|
||||
@@ -207,6 +214,7 @@ export function MainLayout() {
|
||||
const requestLogEnabled = config?.requestLog ?? false;
|
||||
const requestLogDirty = requestLogDraft !== requestLogEnabled;
|
||||
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
|
||||
const isLogsPage = location.pathname.startsWith('/logs');
|
||||
|
||||
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
|
||||
useLayoutEffect(() => {
|
||||
@@ -337,6 +345,7 @@ export function MainLayout() {
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
|
||||
const statusClass =
|
||||
connectionStatus === 'connected'
|
||||
? 'success'
|
||||
@@ -353,6 +362,7 @@ export function MainLayout() {
|
||||
{ path: '/ai-providers', label: t('nav.ai_providers'), icon: sidebarIcons.aiProviders },
|
||||
{ path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles },
|
||||
{ path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth },
|
||||
{ path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota },
|
||||
{ path: '/usage', label: t('nav.usage_stats'), icon: sidebarIcons.usage },
|
||||
{ path: '/config', label: t('nav.config_management'), icon: sidebarIcons.config },
|
||||
...(config?.loggingToFile
|
||||
@@ -360,15 +370,37 @@ export function MainLayout() {
|
||||
: []),
|
||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
||||
];
|
||||
const navOrder = navItems.map((item) => item.path);
|
||||
const getRouteOrder = (pathname: string) => {
|
||||
const trimmedPath =
|
||||
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
const normalizedPath = trimmedPath === '/dashboard' ? '/' : trimmedPath;
|
||||
const exactIndex = navOrder.indexOf(normalizedPath);
|
||||
if (exactIndex !== -1) return exactIndex;
|
||||
const nestedIndex = navOrder.findIndex(
|
||||
(path) => path !== '/' && normalizedPath.startsWith(`${path}/`)
|
||||
);
|
||||
return nestedIndex === -1 ? null : nestedIndex;
|
||||
};
|
||||
|
||||
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 () => {
|
||||
@@ -503,16 +535,20 @@ export function MainLayout() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="content">
|
||||
<main className="main-content">
|
||||
<Outlet />
|
||||
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
|
||||
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
||||
<PageTransition
|
||||
render={(location) => <MainRoutes location={location} />}
|
||||
getRouteOrder={getRouteOrder}
|
||||
scrollContainerRef={contentRef}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<footer className="footer">
|
||||
<span>
|
||||
{t('footer.api_version')}: {serverVersion || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span onClick={handleVersionTap}>
|
||||
<span className="footer-version" onClick={handleVersionTap}>
|
||||
{t('footer.version')}: {__APP_VERSION__ || t('system_info.version_unknown')}
|
||||
</span>
|
||||
<span>
|
||||
|
||||
264
src/components/providers/AmpcodeSection/AmpcodeModal.tsx
Normal file
264
src/components/providers/AmpcodeSection/AmpcodeModal.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { ModelInputList } from '@/components/ui/ModelInputList';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useConfigStore, useNotificationStore } from '@/stores';
|
||||
import { ampcodeApi } from '@/services/api';
|
||||
import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
|
||||
import type { AmpcodeFormState } from '../types';
|
||||
|
||||
interface AmpcodeModalProps {
|
||||
isOpen: boolean;
|
||||
disableControls: boolean;
|
||||
onClose: () => void;
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
|
||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mappingsDirty, setMappingsDirty] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange?.(loading || saving);
|
||||
}, [loading, saving, onBusyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
initializedRef.current = false;
|
||||
setLoading(false);
|
||||
setSaving(false);
|
||||
setError('');
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setForm(buildAmpcodeFormState(null));
|
||||
onBusyChange?.(false);
|
||||
return;
|
||||
}
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
setLoading(true);
|
||||
setLoaded(false);
|
||||
setMappingsDirty(false);
|
||||
setError('');
|
||||
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const ampcode = await ampcodeApi.getAmpcode();
|
||||
setLoaded(true);
|
||||
updateConfigValue('ampcode', ampcode);
|
||||
clearCache('ampcode');
|
||||
setForm(buildAmpcodeFormState(ampcode));
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err) || t('notification.refresh_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
||||
|
||||
const clearAmpcodeUpstreamApiKey = async () => {
|
||||
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await ampcodeApi.clearUpstreamApiKey();
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = { ...previous };
|
||||
delete next.upstreamApiKey;
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const upstreamUrl = form.upstreamUrl.trim();
|
||||
const overrideKey = form.upstreamApiKey.trim();
|
||||
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
|
||||
|
||||
if (upstreamUrl) {
|
||||
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
|
||||
} else {
|
||||
await ampcodeApi.clearUpstreamUrl();
|
||||
}
|
||||
|
||||
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
await ampcodeApi.saveModelMappings(modelMappings);
|
||||
} else {
|
||||
await ampcodeApi.clearModelMappings();
|
||||
}
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
await ampcodeApi.updateUpstreamApiKey(overrideKey);
|
||||
}
|
||||
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = {
|
||||
upstreamUrl: upstreamUrl || undefined,
|
||||
forceModelMappings: form.forceModelMappings,
|
||||
};
|
||||
|
||||
if (previous.upstreamApiKey) {
|
||||
next.upstreamApiKey = previous.upstreamApiKey;
|
||||
}
|
||||
|
||||
if (Array.isArray(previous.modelMappings)) {
|
||||
next.modelMappings = previous.modelMappings;
|
||||
}
|
||||
|
||||
if (overrideKey) {
|
||||
next.upstreamApiKey = overrideKey;
|
||||
}
|
||||
|
||||
if (loaded || mappingsDirty) {
|
||||
if (modelMappings.length) {
|
||||
next.modelMappings = modelMappings;
|
||||
} else {
|
||||
delete next.modelMappings;
|
||||
}
|
||||
}
|
||||
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_updated'), 'success');
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('ai_providers.ampcode_modal_title')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={saving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_url_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
|
||||
value={form.upstreamUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
|
||||
disabled={loading || saving}
|
||||
hint={t('ai_providers.ampcode_upstream_url_hint')}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.ampcode_upstream_api_key_label')}
|
||||
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
|
||||
type="password"
|
||||
value={form.upstreamApiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
|
||||
disabled={loading || saving}
|
||||
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: -8,
|
||||
marginBottom: 12,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div className="hint" style={{ margin: 0 }}>
|
||||
{t('ai_providers.ampcode_upstream_api_key_current', {
|
||||
key: config?.ampcode?.upstreamApiKey
|
||||
? maskApiKey(config.ampcode.upstreamApiKey)
|
||||
: t('common.not_set'),
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={clearAmpcodeUpstreamApiKey}
|
||||
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
|
||||
>
|
||||
{t('ai_providers.ampcode_clear_upstream_api_key')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.ampcode_force_model_mappings_label')}
|
||||
checked={form.forceModelMappings}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
|
||||
<ModelInputList
|
||||
entries={form.mappingEntries}
|
||||
onChange={(entries) => {
|
||||
setMappingsDirty(true);
|
||||
setForm((prev) => ({ ...prev, mappingEntries: entries }));
|
||||
}}
|
||||
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
|
||||
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
|
||||
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
111
src/components/providers/AmpcodeSection/AmpcodeSection.tsx
Normal file
111
src/components/providers/AmpcodeSection/AmpcodeSection.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import iconAmp from '@/assets/icons/amp.svg';
|
||||
import type { AmpcodeConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AmpcodeModal } from './AmpcodeModal';
|
||||
|
||||
interface AmpcodeSectionProps {
|
||||
config: AmpcodeConfig | null | undefined;
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
isBusy: boolean;
|
||||
isModalOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onCloseModal: () => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function AmpcodeSection({
|
||||
config,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
isBusy,
|
||||
isModalOpen,
|
||||
onOpen,
|
||||
onCloseModal,
|
||||
onBusyChange,
|
||||
}: AmpcodeSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={iconAmp} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.ampcode_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpen}
|
||||
disabled={disableControls || isSaving || isBusy || isSwitching}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="hint">{t('common.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_url_label')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.upstreamUrl || t('common.not_set')}</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>
|
||||
{t('ai_providers.ampcode_upstream_api_key_label')}:
|
||||
</span>
|
||||
<span className={styles.fieldValue}>
|
||||
{config?.upstreamApiKey ? maskApiKey(config.upstreamApiKey) : t('common.not_set')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>
|
||||
{t('ai_providers.ampcode_force_model_mappings_label')}:
|
||||
</span>
|
||||
<span className={styles.fieldValue}>
|
||||
{(config?.forceModelMappings ?? false) ? t('common.yes') : t('common.no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.fieldRow} style={{ marginTop: 8 }}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
|
||||
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
|
||||
</div>
|
||||
{config?.modelMappings?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{config.modelMappings.slice(0, 5).map((mapping) => (
|
||||
<span key={`${mapping.from}→${mapping.to}`} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{mapping.from}</span>
|
||||
<span className={styles.modelAlias}>{mapping.to}</span>
|
||||
</span>
|
||||
))}
|
||||
{config.modelMappings.length > 5 && (
|
||||
<span className={styles.modelTag}>
|
||||
<span className={styles.modelName}>+{config.modelMappings.length - 5}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AmpcodeModal
|
||||
isOpen={isModalOpen}
|
||||
disableControls={disableControls}
|
||||
onClose={onCloseModal}
|
||||
onBusyChange={onBusyChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/AmpcodeSection/index.ts
Normal file
1
src/components/providers/AmpcodeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AmpcodeSection } from './AmpcodeSection';
|
||||
128
src/components/providers/ClaudeSection/ClaudeModal.tsx
Normal file
128
src/components/providers/ClaudeSection/ClaudeModal.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
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 { excludedModelsToText } from '../utils';
|
||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||
|
||||
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
export function ClaudeModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: ClaudeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<ProviderFormState>(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),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.claude_edit_modal_title')
|
||||
: t('ai_providers.claude_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.claude_add_modal_key_label')}
|
||||
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.claude_add_modal_url_label')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.claude_add_modal_proxy_label')}
|
||||
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.claude_models_label')}</label>
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
addLabel={t('ai_providers.claude_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
202
src/components/providers/ClaudeSection/ClaudeSection.tsx
Normal file
202
src/components/providers/ClaudeSection/ClaudeSection.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import type { ProviderFormState } from '../types';
|
||||
import { ClaudeModal } from './ClaudeModal';
|
||||
|
||||
interface ClaudeSectionProps {
|
||||
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;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ClaudeSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: ClaudeSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || 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={iconClaude} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.claude_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.claude_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
emptyTitle={t('ai_providers.claude_empty_title')}
|
||||
emptyDescription={t('ai_providers.claude_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.config_toggle_label')}
|
||||
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">{t('ai_providers.claude_item_title')}</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>
|
||||
)}
|
||||
{configDisabled && (
|
||||
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
<span className={styles.modelCountLabel}>
|
||||
{t('ai_providers.claude_models_count')}: {item.models.length}
|
||||
</span>
|
||||
{item.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||
</div>
|
||||
<div className={styles.modelTagList}>
|
||||
{excludedModels.map((model) => (
|
||||
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<ClaudeModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/ClaudeSection/index.ts
Normal file
1
src/components/providers/ClaudeSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClaudeSection } from './ClaudeSection';
|
||||
117
src/components/providers/CodexSection/CodexModal.tsx
Normal file
117
src/components/providers/CodexSection/CodexModal.tsx
Normal 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 type { ProviderKeyConfig } from '@/types';
|
||||
import { headersToEntries } from '@/utils/headers';
|
||||
import { modelsToEntries } from '@/components/ui/ModelInputList';
|
||||
import { excludedModelsToText } from '../utils';
|
||||
import type { ProviderFormState, ProviderModalProps } from '../types';
|
||||
|
||||
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): ProviderFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
proxyUrl: '',
|
||||
headers: [],
|
||||
models: [],
|
||||
excludedModels: [],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
export function CodexModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: CodexModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<ProviderFormState>(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),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.codex_edit_modal_title')
|
||||
: t('ai_providers.codex_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.codex_add_modal_key_label')}
|
||||
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.codex_add_modal_url_label')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label={t('ai_providers.codex_add_modal_proxy_label')}
|
||||
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.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
194
src/components/providers/CodexSection/CodexSection.tsx
Normal file
194
src/components/providers/CodexSection/CodexSection.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import type { ProviderFormState } from '../types';
|
||||
import { CodexModal } from './CodexModal';
|
||||
|
||||
interface CodexSectionProps {
|
||||
configs: ProviderKeyConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: ProviderFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CodexSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: CodexSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || 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={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t('ai_providers.codex_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.codex_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<ProviderKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
emptyTitle={t('ai_providers.codex_empty_title')}
|
||||
emptyDescription={t('ai_providers.codex_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.config_toggle_label')}
|
||||
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">{t('ai_providers.codex_item_title')}</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>
|
||||
)}
|
||||
{configDisabled && (
|
||||
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||
</div>
|
||||
<div className={styles.modelTagList}>
|
||||
{excludedModels.map((model) => (
|
||||
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<CodexModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/CodexSection/index.ts
Normal file
1
src/components/providers/CodexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CodexSection } from './CodexSection';
|
||||
113
src/components/providers/GeminiSection/GeminiModal.tsx
Normal file
113
src/components/providers/GeminiSection/GeminiModal.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
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 type { GeminiKeyConfig } from '@/types';
|
||||
import { headersToEntries } from '@/utils/headers';
|
||||
import { excludedModelsToText } from '../utils';
|
||||
import type { GeminiFormState, ProviderModalProps } from '../types';
|
||||
|
||||
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): GeminiFormState => ({
|
||||
apiKey: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
excludedModels: [],
|
||||
excludedText: '',
|
||||
});
|
||||
|
||||
export function GeminiModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: GeminiModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (initialData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setForm({
|
||||
...initialData,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
excludedText: excludedModelsToText(initialData.excludedModels),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm(buildEmptyForm());
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
const handleSave = () => {
|
||||
void onSave(form, editIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.gemini_edit_modal_title')
|
||||
: t('ai_providers.gemini_add_modal_title')
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={isSaving}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
label={t('ai_providers.gemini_add_modal_key_label')}
|
||||
placeholder={t('ai_providers.gemini_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.gemini_base_url_label')}
|
||||
placeholder={t('ai_providers.gemini_base_url_placeholder')}
|
||||
value={form.baseUrl ?? ''}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: 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.excluded_models_label')}</label>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder={t('ai_providers.excluded_models_placeholder')}
|
||||
value={form.excludedText}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
183
src/components/providers/GeminiSection/GeminiSection.tsx
Normal file
183
src/components/providers/GeminiSection/GeminiSection.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import type { GeminiFormState } from '../types';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
|
||||
import { GeminiModal } from './GeminiModal';
|
||||
|
||||
interface GeminiSectionProps {
|
||||
configs: GeminiKeyConfig[];
|
||||
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;
|
||||
onToggle: (index: number, enabled: boolean) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: GeminiFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function GeminiSection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: GeminiSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
const toggleDisabled = disableControls || loading || 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={iconGemini} alt="" className={styles.cardTitleIcon} />
|
||||
{t('ai_providers.gemini_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.gemini_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<GeminiKeyConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.apiKey}
|
||||
emptyTitle={t('ai_providers.gemini_empty_title')}
|
||||
emptyDescription={t('ai_providers.gemini_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
|
||||
renderExtraActions={(item, index) => (
|
||||
<ToggleSwitch
|
||||
label={t('ai_providers.config_toggle_label')}
|
||||
checked={!hasDisableAllModelsRule(item.excludedModels)}
|
||||
disabled={toggleDisabled}
|
||||
onChange={(value) => void onToggle(index, value)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">
|
||||
{t('ai_providers.gemini_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>
|
||||
)}
|
||||
{headerEntries.length > 0 && (
|
||||
<div className={styles.headerBadgeList}>
|
||||
{headerEntries.map(([key, value]) => (
|
||||
<span key={key} className={styles.headerBadge}>
|
||||
<strong>{key}:</strong> {value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{configDisabled && (
|
||||
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
{t('ai_providers.config_disabled_badge')}
|
||||
</div>
|
||||
)}
|
||||
{excludedModels.length ? (
|
||||
<div className={styles.excludedModelsSection}>
|
||||
<div className={styles.excludedModelsLabel}>
|
||||
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
|
||||
</div>
|
||||
<div className={styles.modelTagList}>
|
||||
{excludedModels.map((model) => (
|
||||
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<GeminiModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/GeminiSection/index.ts
Normal file
1
src/components/providers/GeminiSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GeminiSection } from './GeminiSection';
|
||||
194
src/components/providers/OpenAISection/OpenAIDiscoveryModal.tsx
Normal file
194
src/components/providers/OpenAISection/OpenAIDiscoveryModal.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { modelsApi } from '@/services/api';
|
||||
import type { ApiKeyEntry } from '@/types';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
||||
import { buildOpenAIModelsEndpoint } from '../utils';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
|
||||
interface OpenAIDiscoveryModalProps {
|
||||
isOpen: boolean;
|
||||
baseUrl: string;
|
||||
headers: HeaderEntry[];
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
onClose: () => void;
|
||||
onApply: (selected: ModelInfo[]) => void;
|
||||
}
|
||||
|
||||
export function OpenAIDiscoveryModal({
|
||||
isOpen,
|
||||
baseUrl,
|
||||
headers,
|
||||
apiKeyEntries,
|
||||
onClose,
|
||||
onApply,
|
||||
}: OpenAIDiscoveryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
const filter = search.trim().toLowerCase();
|
||||
if (!filter) return models;
|
||||
return models.filter((model) => {
|
||||
const name = (model.name || '').toLowerCase();
|
||||
const alias = (model.alias || '').toLowerCase();
|
||||
const desc = (model.description || '').toLowerCase();
|
||||
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
|
||||
});
|
||||
}, [models, search]);
|
||||
|
||||
const fetchOpenaiModelDiscovery = useCallback(
|
||||
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
|
||||
const trimmedBaseUrl = baseUrl.trim();
|
||||
if (!trimmedBaseUrl) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const headerObject = buildHeaderObject(headers);
|
||||
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
trimmedBaseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
headerObject
|
||||
);
|
||||
setModels(list);
|
||||
} catch (err: unknown) {
|
||||
if (allowFallback) {
|
||||
try {
|
||||
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
|
||||
setModels(list);
|
||||
return;
|
||||
} catch (fallbackErr: unknown) {
|
||||
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
|
||||
setModels([]);
|
||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
|
||||
}
|
||||
} else {
|
||||
setModels([]);
|
||||
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiKeyEntries, baseUrl, headers, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
|
||||
setModels([]);
|
||||
setSearch('');
|
||||
setSelected(new Set());
|
||||
setError('');
|
||||
void fetchOpenaiModelDiscovery();
|
||||
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
|
||||
|
||||
const toggleSelection = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const selectedModels = models.filter((model) => selected.has(model.name));
|
||||
onApply(selectedModels);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('ai_providers.openai_models_fetch_title')}
|
||||
width={720}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
{t('ai_providers.openai_models_fetch_back')}
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={loading}>
|
||||
{t('ai_providers.openai_models_fetch_apply')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>
|
||||
{t('ai_providers.openai_models_fetch_hint')}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input className="input" readOnly value={endpoint} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
|
||||
loading={loading}
|
||||
>
|
||||
{t('ai_providers.openai_models_fetch_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label={t('ai_providers.openai_models_search_label')}
|
||||
placeholder={t('ai_providers.openai_models_search_placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
|
||||
) : (
|
||||
<div className={styles.modelDiscoveryList}>
|
||||
{filteredModels.map((model) => {
|
||||
const checked = selected.has(model.name);
|
||||
return (
|
||||
<label
|
||||
key={model.name}
|
||||
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
|
||||
>
|
||||
<input type="checkbox" checked={checked} onChange={() => toggleSelection(model.name)} />
|
||||
<div className={styles.modelDiscoveryMeta}>
|
||||
<div className={styles.modelDiscoveryName}>
|
||||
{model.name}
|
||||
{model.alias && <span className={styles.modelDiscoveryAlias}>{model.alias}</span>}
|
||||
</div>
|
||||
{model.description && (
|
||||
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
432
src/components/providers/OpenAISection/OpenAIModal.tsx
Normal file
432
src/components/providers/OpenAISection/OpenAIModal.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useEffect, useMemo, 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 { useNotificationStore } from '@/stores';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
|
||||
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
|
||||
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
|
||||
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
|
||||
|
||||
const OPENAI_TEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const buildEmptyForm = (): OpenAIFormState => ({
|
||||
name: '',
|
||||
prefix: '',
|
||||
baseUrl: '',
|
||||
headers: [],
|
||||
apiKeyEntries: [buildApiKeyEntry()],
|
||||
modelEntries: [{ name: '', alias: '' }],
|
||||
testModel: undefined,
|
||||
});
|
||||
|
||||
export function OpenAIModal({
|
||||
isOpen,
|
||||
editIndex,
|
||||
initialData,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: OpenAIModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
|
||||
const [discoveryOpen, setDiscoveryOpen] = useState(false);
|
||||
const [testModel, setTestModel] = useState('');
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [testMessage, setTestMessage] = useState('');
|
||||
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'string') return err;
|
||||
return '';
|
||||
};
|
||||
|
||||
const availableModels = useMemo(
|
||||
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
|
||||
[form.modelEntries]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setDiscoveryOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialData) {
|
||||
const modelEntries = modelsToEntries(initialData.models);
|
||||
setForm({
|
||||
name: initialData.name,
|
||||
prefix: initialData.prefix ?? '',
|
||||
baseUrl: initialData.baseUrl,
|
||||
headers: headersToEntries(initialData.headers),
|
||||
testModel: initialData.testModel,
|
||||
modelEntries,
|
||||
apiKeyEntries: initialData.apiKeyEntries?.length
|
||||
? initialData.apiKeyEntries
|
||||
: [buildApiKeyEntry()],
|
||||
});
|
||||
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
|
||||
const initialModel =
|
||||
initialData.testModel && available.includes(initialData.testModel)
|
||||
? initialData.testModel
|
||||
: available[0] || '';
|
||||
setTestModel(initialModel);
|
||||
} else {
|
||||
setForm(buildEmptyForm());
|
||||
setTestModel('');
|
||||
}
|
||||
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
setDiscoveryOpen(false);
|
||||
}, [initialData, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (availableModels.length === 0) {
|
||||
if (testModel) {
|
||||
setTestModel('');
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!testModel || !availableModels.includes(testModel)) {
|
||||
setTestModel(availableModels[0]);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}
|
||||
}, [availableModels, isOpen, testModel]);
|
||||
|
||||
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
|
||||
const list = entries.length ? entries : [buildApiKeyEntry()];
|
||||
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
|
||||
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
|
||||
};
|
||||
|
||||
const removeEntry = (idx: number) => {
|
||||
const next = list.filter((_, i) => i !== idx);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
|
||||
}));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{list.map((entry, index) => (
|
||||
<div key={index} className="item-row">
|
||||
<div className="item-meta">
|
||||
<Input
|
||||
label={`${t('common.api_key')} #${index + 1}`}
|
||||
value={entry.apiKey}
|
||||
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t('common.proxy_url')}
|
||||
value={entry.proxyUrl ?? ''}
|
||||
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEntry(index)}
|
||||
disabled={list.length <= 1 || isSaving}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
|
||||
{t('ai_providers.openai_keys_add_btn')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openOpenaiModelDiscovery = () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
|
||||
return;
|
||||
}
|
||||
setDiscoveryOpen(true);
|
||||
};
|
||||
|
||||
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
|
||||
if (!selectedModels.length) {
|
||||
setDiscoveryOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedMap = new Map<string, ModelEntry>();
|
||||
form.modelEntries.forEach((entry) => {
|
||||
const name = entry.name.trim();
|
||||
if (!name) return;
|
||||
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
|
||||
});
|
||||
|
||||
let addedCount = 0;
|
||||
selectedModels.forEach((model) => {
|
||||
const name = model.name.trim();
|
||||
if (!name || mergedMap.has(name)) return;
|
||||
mergedMap.set(name, { name, alias: model.alias ?? '' });
|
||||
addedCount += 1;
|
||||
});
|
||||
|
||||
const mergedEntries = Array.from(mergedMap.values());
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
|
||||
}));
|
||||
|
||||
setDiscoveryOpen(false);
|
||||
if (addedCount > 0) {
|
||||
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const testOpenaiProviderConnection = async () => {
|
||||
const baseUrl = form.baseUrl.trim();
|
||||
if (!baseUrl) {
|
||||
const message = t('notification.openai_test_url_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
const message = t('notification.openai_test_url_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
|
||||
if (!firstKeyEntry) {
|
||||
const message = t('notification.openai_test_key_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const modelName = testModel.trim() || availableModels[0] || '';
|
||||
if (!modelName) {
|
||||
const message = t('notification.openai_test_model_required');
|
||||
setTestStatus('error');
|
||||
setTestMessage(message);
|
||||
showNotification(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const customHeaders = buildHeaderObject(form.headers);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
if (!headers.Authorization && !headers['authorization']) {
|
||||
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
|
||||
}
|
||||
|
||||
setTestStatus('loading');
|
||||
setTestMessage(t('ai_providers.openai_test_running'));
|
||||
|
||||
try {
|
||||
const result = await apiCallApi.request(
|
||||
{
|
||||
method: 'POST',
|
||||
url: endpoint,
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
data: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stream: false,
|
||||
max_tokens: 5,
|
||||
}),
|
||||
},
|
||||
{ timeout: OPENAI_TEST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
setTestStatus('success');
|
||||
setTestMessage(t('ai_providers.openai_test_success'));
|
||||
} catch (err: unknown) {
|
||||
setTestStatus('error');
|
||||
const message = getErrorMessage(err);
|
||||
const errorCode =
|
||||
typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : '';
|
||||
const isTimeout =
|
||||
errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
|
||||
if (isTimeout) {
|
||||
setTestMessage(t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }));
|
||||
} else {
|
||||
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
editIndex !== null
|
||||
? t('ai_providers.openai_edit_modal_title')
|
||||
: t('ai_providers.openai_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.openai_add_modal_name_label')}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: 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.openai_add_modal_url_label')}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: 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>
|
||||
{editIndex !== null
|
||||
? t('ai_providers.openai_edit_modal_models_label')
|
||||
: t('ai_providers.openai_add_modal_models_label')}
|
||||
</label>
|
||||
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
|
||||
<ModelInputList
|
||||
entries={form.modelEntries}
|
||||
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
|
||||
addLabel={t('ai_providers.openai_models_add_btn')}
|
||||
namePlaceholder={t('common.model_name_placeholder')}
|
||||
aliasPlaceholder={t('common.model_alias_placeholder')}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
|
||||
{t('ai_providers.openai_models_fetch_button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_test_title')}</label>
|
||||
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select
|
||||
className={`input ${styles.openaiTestSelect}`}
|
||||
value={testModel}
|
||||
onChange={(e) => {
|
||||
setTestModel(e.target.value);
|
||||
setTestStatus('idle');
|
||||
setTestMessage('');
|
||||
}}
|
||||
disabled={isSaving || availableModels.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{availableModels.length
|
||||
? t('ai_providers.openai_test_select_placeholder')
|
||||
: t('ai_providers.openai_test_select_empty')}
|
||||
</option>
|
||||
{form.modelEntries
|
||||
.filter((entry) => entry.name.trim())
|
||||
.map((entry, idx) => {
|
||||
const name = entry.name.trim();
|
||||
const alias = entry.alias.trim();
|
||||
const label = alias && alias !== name ? `${name} (${alias})` : name;
|
||||
return (
|
||||
<option key={`${name}-${idx}`} value={name}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<Button
|
||||
variant={testStatus === 'error' ? 'danger' : 'secondary'}
|
||||
className={`${styles.openaiTestButton} ${testStatus === 'success' ? styles.openaiTestButtonSuccess : ''}`}
|
||||
onClick={testOpenaiProviderConnection}
|
||||
loading={testStatus === 'loading'}
|
||||
disabled={isSaving || availableModels.length === 0}
|
||||
>
|
||||
{t('ai_providers.openai_test_action')}
|
||||
</Button>
|
||||
</div>
|
||||
{testMessage && (
|
||||
<div
|
||||
className={`status-badge ${
|
||||
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
|
||||
}`}
|
||||
>
|
||||
{testMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
|
||||
{renderKeyEntries(form.apiKeyEntries)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<OpenAIDiscoveryModal
|
||||
isOpen={discoveryOpen}
|
||||
baseUrl={form.baseUrl}
|
||||
headers={form.headers}
|
||||
apiKeyEntries={form.apiKeyEntries}
|
||||
onClose={() => setDiscoveryOpen(false)}
|
||||
onApply={applyOpenaiModelDiscoverySelection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
206
src/components/providers/OpenAISection/OpenAISection.tsx
Normal file
206
src/components/providers/OpenAISection/OpenAISection.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
|
||||
import type { OpenAIFormState } from '../types';
|
||||
import { OpenAIModal } from './OpenAIModal';
|
||||
|
||||
interface OpenAISectionProps {
|
||||
configs: OpenAIProviderConfig[];
|
||||
keyStats: KeyStats;
|
||||
usageDetails: UsageDetail[];
|
||||
loading: boolean;
|
||||
disableControls: boolean;
|
||||
isSaving: boolean;
|
||||
isSwitching: boolean;
|
||||
resolvedTheme: string;
|
||||
isModalOpen: boolean;
|
||||
modalIndex: number | null;
|
||||
onAdd: () => void;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onCloseModal: () => void;
|
||||
onSave: (data: OpenAIFormState, index: number | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function OpenAISection({
|
||||
configs,
|
||||
keyStats,
|
||||
usageDetails,
|
||||
loading,
|
||||
disableControls,
|
||||
isSaving,
|
||||
isSwitching,
|
||||
resolvedTheme,
|
||||
isModalOpen,
|
||||
modalIndex,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCloseModal,
|
||||
onSave,
|
||||
}: OpenAISectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionsDisabled = disableControls || isSaving || isSwitching;
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((provider) => {
|
||||
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean);
|
||||
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t('ai_providers.openai_title')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
|
||||
{t('ai_providers.openai_add_button')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProviderList<OpenAIProviderConfig>
|
||||
items={configs}
|
||||
loading={loading}
|
||||
keyField={(item) => item.name}
|
||||
emptyTitle={t('ai_providers.openai_empty_title')}
|
||||
emptyDescription={t('ai_providers.openai_empty_desc')}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item) => {
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const apiKeyEntries = item.apiKeyEntries || [];
|
||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="item-title">{item.name}</div>
|
||||
{item.prefix && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
|
||||
<span className={styles.fieldValue}>{item.prefix}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
|
||||
<span className={styles.fieldValue}>{item.baseUrl}</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>
|
||||
)}
|
||||
{apiKeyEntries.length > 0 && (
|
||||
<div className={styles.apiKeyEntriesSection}>
|
||||
<div className={styles.apiKeyEntriesLabel}>
|
||||
{t('ai_providers.openai_keys_count')}: {apiKeyEntries.length}
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey);
|
||||
return (
|
||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
|
||||
{entry.proxyUrl && (
|
||||
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
|
||||
)}
|
||||
<div className={styles.apiKeyEntryStats}>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
|
||||
>
|
||||
<IconCheck size={12} /> {entryStats.success}
|
||||
</span>
|
||||
<span
|
||||
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
|
||||
>
|
||||
<IconX size={12} /> {entryStats.failure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.fieldRow} style={{ marginTop: '8px' }}>
|
||||
<span className={styles.fieldLabel}>{t('ai_providers.openai_models_count')}:</span>
|
||||
<span className={styles.fieldValue}>{item.models?.length || 0}</span>
|
||||
</div>
|
||||
{item.models?.length ? (
|
||||
<div className={styles.modelTagList}>
|
||||
{item.models.map((model) => (
|
||||
<span key={model.name} className={styles.modelTag}>
|
||||
<span className={styles.modelName}>{model.name}</span>
|
||||
{model.alias && model.alias !== model.name && (
|
||||
<span className={styles.modelAlias}>{model.alias}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{item.testModel && (
|
||||
<div className={styles.fieldRow}>
|
||||
<span className={styles.fieldLabel}>Test Model:</span>
|
||||
<span className={styles.fieldValue}>{item.testModel}</span>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<OpenAIModal
|
||||
isOpen={isModalOpen}
|
||||
editIndex={modalIndex}
|
||||
initialData={initialData}
|
||||
onClose={onCloseModal}
|
||||
onSave={onSave}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/OpenAISection/index.ts
Normal file
1
src/components/providers/OpenAISection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { OpenAISection } from './OpenAISection';
|
||||
80
src/components/providers/ProviderList.tsx
Normal file
80
src/components/providers/ProviderList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
|
||||
interface ProviderListProps<T> {
|
||||
items: T[];
|
||||
loading: boolean;
|
||||
keyField: (item: T) => string;
|
||||
renderContent: (item: T, index: number) => ReactNode;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
deleteLabel?: string;
|
||||
actionsDisabled?: boolean;
|
||||
getRowDisabled?: (item: T, index: number) => boolean;
|
||||
renderExtraActions?: (item: T, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
export function ProviderList<T>({
|
||||
items,
|
||||
loading,
|
||||
keyField,
|
||||
renderContent,
|
||||
onEdit,
|
||||
onDelete,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
deleteLabel,
|
||||
actionsDisabled = false,
|
||||
getRowDisabled,
|
||||
renderExtraActions,
|
||||
}: ProviderListProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (loading) {
|
||||
return <div className="hint">{t('common.loading')}</div>;
|
||||
}
|
||||
|
||||
if (!items.length) {
|
||||
return <EmptyState title={emptyTitle} description={emptyDescription} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="item-list">
|
||||
{items.map((item, index) => {
|
||||
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
|
||||
return (
|
||||
<div
|
||||
key={keyField(item)}
|
||||
className="item-row"
|
||||
style={rowDisabled ? { opacity: 0.6 } : undefined}
|
||||
>
|
||||
<div className="item-meta">{renderContent(item, index)}</div>
|
||||
<div className="item-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onEdit(index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => onDelete(index)}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
{deleteLabel || t('common.delete')}
|
||||
</Button>
|
||||
{renderExtraActions ? renderExtraActions(item, index) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/providers/ProviderStatusBar.tsx
Normal file
38
src/components/providers/ProviderStatusBar.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { calculateStatusBarData } from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
|
||||
interface ProviderStatusBarProps {
|
||||
statusData: ReturnType<typeof calculateStatusBarData>;
|
||||
}
|
||||
|
||||
export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) {
|
||||
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||
const rateClass = !hasData
|
||||
? ''
|
||||
: statusData.successRate >= 90
|
||||
? styles.statusRateHigh
|
||||
: statusData.successRate >= 50
|
||||
? styles.statusRateMedium
|
||||
: styles.statusRateLow;
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
<div className={styles.statusBlocks}>
|
||||
{statusData.blocks.map((state, idx) => {
|
||||
const blockClass =
|
||||
state === 'success'
|
||||
? styles.statusBlockSuccess
|
||||
: state === 'failure'
|
||||
? styles.statusBlockFailure
|
||||
: state === 'mixed'
|
||||
? styles.statusBlockMixed
|
||||
: styles.statusBlockIdle;
|
||||
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
||||
})}
|
||||
</div>
|
||||
<span className={`${styles.statusRate} ${rateClass}`}>
|
||||
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
src/components/providers/VertexSection/VertexModal.tsx
Normal file
117
src/components/providers/VertexSection/VertexModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
src/components/providers/VertexSection/VertexSection.tsx
Normal file
170
src/components/providers/VertexSection/VertexSection.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/components/providers/VertexSection/index.ts
Normal file
1
src/components/providers/VertexSection/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VertexSection } from './VertexSection';
|
||||
37
src/components/providers/hooks/useProviderStats.ts
Normal file
37
src/components/providers/hooks/useProviderStats.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { usageApi } from '@/services/api';
|
||||
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
|
||||
const EMPTY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
|
||||
|
||||
export const useProviderStats = () => {
|
||||
const [keyStats, setKeyStats] = useState<KeyStats>(EMPTY_STATS);
|
||||
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||
const loadKeyStats = useCallback(async () => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const usageResponse = await usageApi.getUsage();
|
||||
const usageData = usageResponse?.usage ?? usageResponse;
|
||||
const stats = await usageApi.getKeyStats(usageData);
|
||||
setKeyStats(stats);
|
||||
setUsageDetails(collectUsageDetails(usageData));
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 定时刷新状态数据(每240秒)
|
||||
useInterval(loadKeyStats, 240_000);
|
||||
|
||||
return { keyStats, usageDetails, loadKeyStats, isLoading };
|
||||
};
|
||||
11
src/components/providers/index.ts
Normal file
11
src/components/providers/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { AmpcodeSection } from './AmpcodeSection';
|
||||
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';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
69
src/components/providers/types.ts
Normal file
69
src/components/providers/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
|
||||
import type { HeaderEntry } from '@/utils/headers';
|
||||
import type { KeyStats, UsageDetail } from '@/utils/usage';
|
||||
|
||||
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 };
|
||||
|
||||
export interface ModelEntry {
|
||||
name: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export interface OpenAIFormState {
|
||||
name: string;
|
||||
prefix: string;
|
||||
baseUrl: string;
|
||||
headers: HeaderEntry[];
|
||||
testModel?: string;
|
||||
modelEntries: ModelEntry[];
|
||||
apiKeyEntries: ApiKeyEntry[];
|
||||
}
|
||||
|
||||
export interface AmpcodeFormState {
|
||||
upstreamUrl: string;
|
||||
upstreamApiKey: string;
|
||||
forceModelMappings: boolean;
|
||||
mappingEntries: ModelEntry[];
|
||||
}
|
||||
|
||||
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
|
||||
headers: HeaderEntry[];
|
||||
excludedText: string;
|
||||
};
|
||||
|
||||
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;
|
||||
usageDetails: UsageDetail[];
|
||||
disabled: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onAdd: () => void;
|
||||
onDelete: (index: number) => void;
|
||||
onToggle?: (index: number, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
|
||||
isOpen: boolean;
|
||||
editIndex: number | null;
|
||||
initialData?: TConfig;
|
||||
onClose: () => void;
|
||||
onSave: (data: TPayload, index: number | null) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
132
src/components/providers/utils.ts
Normal file
132
src/components/providers/utils.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||
import type { KeyStatBucket, KeyStats } from '@/utils/usage';
|
||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
|
||||
export const hasDisableAllModelsRule = (models?: string[]) =>
|
||||
Array.isArray(models) &&
|
||||
models.some((model) => String(model ?? '').trim() === DISABLE_ALL_MODELS_RULE);
|
||||
|
||||
export const stripDisableAllModelsRule = (models?: string[]) =>
|
||||
Array.isArray(models)
|
||||
? models.filter((model) => String(model ?? '').trim() !== DISABLE_ALL_MODELS_RULE)
|
||||
: [];
|
||||
|
||||
export const withDisableAllModelsRule = (models?: string[]) => {
|
||||
const base = stripDisableAllModelsRule(models);
|
||||
return [...base, DISABLE_ALL_MODELS_RULE];
|
||||
};
|
||||
|
||||
export const withoutDisableAllModelsRule = (models?: string[]) => {
|
||||
const base = stripDisableAllModelsRule(models);
|
||||
return base;
|
||||
};
|
||||
|
||||
export const parseExcludedModels = (text: string): string[] =>
|
||||
text
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export const excludedModelsToText = (models?: string[]) =>
|
||||
Array.isArray(models) ? models.join('\n') : '';
|
||||
|
||||
export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||
let trimmed = String(baseUrl || '').trim();
|
||||
if (!trimmed) return '';
|
||||
trimmed = trimmed.replace(/\/?v0\/management\/?$/i, '');
|
||||
trimmed = trimmed.replace(/\/+$/g, '');
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
trimmed = `http://${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||
};
|
||||
|
||||
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.endsWith('/chat/completions')) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`;
|
||||
};
|
||||
|
||||
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
|
||||
export const getStatsBySource = (
|
||||
apiKey: string,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
const masked = maskFn(apiKey);
|
||||
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 };
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
let totalSuccess = 0;
|
||||
let totalFailure = 0;
|
||||
|
||||
(apiKeyEntries || []).forEach((entry) => {
|
||||
const key = entry?.apiKey || '';
|
||||
if (!key) return;
|
||||
const masked = maskFn(key);
|
||||
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
|
||||
totalSuccess += stats.success;
|
||||
totalFailure += stats.failure;
|
||||
});
|
||||
|
||||
return { success: totalSuccess, failure: totalFailure };
|
||||
};
|
||||
|
||||
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
apiKey: input?.apiKey ?? '',
|
||||
proxyUrl: input?.proxyUrl ?? '',
|
||||
headers: input?.headers ?? {},
|
||||
});
|
||||
|
||||
export const ampcodeMappingsToEntries = (mappings?: AmpcodeModelMapping[]): ModelEntry[] => {
|
||||
if (!Array.isArray(mappings) || mappings.length === 0) {
|
||||
return [{ name: '', alias: '' }];
|
||||
}
|
||||
return mappings.map((mapping) => ({
|
||||
name: mapping.from ?? '',
|
||||
alias: mapping.to ?? '',
|
||||
}));
|
||||
};
|
||||
|
||||
export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMapping[] => {
|
||||
const seen = new Set<string>();
|
||||
const mappings: AmpcodeModelMapping[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const from = entry.name.trim();
|
||||
const to = entry.alias.trim();
|
||||
if (!from || !to) return;
|
||||
const key = from.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
mappings.push({ from, to });
|
||||
});
|
||||
|
||||
return mappings;
|
||||
};
|
||||
|
||||
export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({
|
||||
upstreamUrl: ampcode?.upstreamUrl ?? '',
|
||||
upstreamApiKey: '',
|
||||
forceModelMappings: ampcode?.forceModelMappings ?? false,
|
||||
mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings),
|
||||
});
|
||||
145
src/components/quota/QuotaCard.tsx
Normal file
145
src/components/quota/QuotaCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Generic quota card component.
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types';
|
||||
import { TYPE_COLORS } from '@/utils/quota';
|
||||
import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export interface QuotaStatusState {
|
||||
status: QuotaStatus;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
export interface QuotaProgressBarProps {
|
||||
percent: number | null;
|
||||
highThreshold: number;
|
||||
mediumThreshold: number;
|
||||
}
|
||||
|
||||
export function QuotaProgressBar({
|
||||
percent,
|
||||
highThreshold,
|
||||
mediumThreshold
|
||||
}: QuotaProgressBarProps) {
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
const normalized = percent === null ? null : clamp(percent, 0, 100);
|
||||
const fillClass =
|
||||
normalized === null
|
||||
? styles.quotaBarFillMedium
|
||||
: normalized >= highThreshold
|
||||
? styles.quotaBarFillHigh
|
||||
: normalized >= mediumThreshold
|
||||
? styles.quotaBarFillMedium
|
||||
: styles.quotaBarFillLow;
|
||||
const widthPercent = Math.round(normalized ?? 0);
|
||||
|
||||
return (
|
||||
<div className={styles.quotaBar}>
|
||||
<div
|
||||
className={`${styles.quotaBarFill} ${fillClass}`}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface QuotaRenderHelpers {
|
||||
styles: typeof styles;
|
||||
QuotaProgressBar: (props: QuotaProgressBarProps) => ReactElement;
|
||||
}
|
||||
|
||||
interface QuotaCardProps<TState extends QuotaStatusState> {
|
||||
item: AuthFileItem;
|
||||
quota?: TState;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
i18nPrefix: string;
|
||||
cardClassName: string;
|
||||
defaultType: string;
|
||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||
}
|
||||
|
||||
export function QuotaCard<TState extends QuotaStatusState>({
|
||||
item,
|
||||
quota,
|
||||
resolvedTheme,
|
||||
i18nPrefix,
|
||||
cardClassName,
|
||||
defaultType,
|
||||
renderQuotaItems
|
||||
}: QuotaCardProps<TState>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const displayType = item.type || item.provider || defaultType;
|
||||
const typeColorSet = TYPE_COLORS[displayType] || TYPE_COLORS.unknown;
|
||||
const typeColor: ThemeColors =
|
||||
resolvedTheme === 'dark' && typeColorSet.dark ? typeColorSet.dark : typeColorSet.light;
|
||||
|
||||
const quotaStatus = quota?.status ?? 'idle';
|
||||
const quotaErrorMessage = resolveQuotaErrorMessage(
|
||||
t,
|
||||
quota?.errorStatus,
|
||||
quota?.error || t('common.unknown_error')
|
||||
);
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const key = `auth_files.filter_${type}`;
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.fileCard} ${cardClassName}`}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span
|
||||
className={styles.typeBadge}
|
||||
style={{
|
||||
backgroundColor: typeColor.bg,
|
||||
color: typeColor.text,
|
||||
...(typeColor.border ? { border: typeColor.border } : {})
|
||||
}}
|
||||
>
|
||||
{getTypeLabel(displayType)}
|
||||
</span>
|
||||
<span className={styles.fileName}>{item.name}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.quotaSection}>
|
||||
{quotaStatus === 'loading' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.loading`)}</div>
|
||||
) : quotaStatus === 'idle' ? (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||
) : quotaStatus === 'error' ? (
|
||||
<div className={styles.quotaError}>
|
||||
{t(`${i18nPrefix}.load_failed`, {
|
||||
message: quotaErrorMessage
|
||||
})}
|
||||
</div>
|
||||
) : quota ? (
|
||||
renderQuotaItems(quota, t, { styles, QuotaProgressBar })
|
||||
) : (
|
||||
<div className={styles.quotaMessage}>{t(`${i18nPrefix}.idle`)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolveQuotaErrorMessage = (
|
||||
t: TFunction,
|
||||
status: number | undefined,
|
||||
fallback: string
|
||||
): string => {
|
||||
if (status === 404) return t('common.quota_update_required');
|
||||
if (status === 403) return t('common.quota_check_credential');
|
||||
return fallback;
|
||||
};
|
||||
321
src/components/quota/QuotaSection.tsx
Normal file
321
src/components/quota/QuotaSection.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Generic quota section component.
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
type ViewMode = 'paged' | 'all';
|
||||
|
||||
const MAX_ITEMS_PER_PAGE = 14;
|
||||
const MAX_SHOW_ALL_THRESHOLD = 30;
|
||||
|
||||
interface QuotaPaginationState<T> {
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
pageItems: T[];
|
||||
setPageSize: (size: number) => void;
|
||||
goToPrev: () => void;
|
||||
goToNext: () => void;
|
||||
loading: boolean;
|
||||
loadingScope: 'page' | 'all' | null;
|
||||
setLoading: (loading: boolean, scope?: 'page' | 'all' | null) => void;
|
||||
}
|
||||
|
||||
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSizeState] = useState(defaultPageSize);
|
||||
const [loading, setLoadingState] = useState(false);
|
||||
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil(items.length / pageSize)),
|
||||
[items.length, pageSize]
|
||||
);
|
||||
|
||||
const currentPage = useMemo(() => Math.min(page, totalPages), [page, totalPages]);
|
||||
|
||||
const pageItems = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return items.slice(start, start + pageSize);
|
||||
}, [items, currentPage, pageSize]);
|
||||
|
||||
const setPageSize = useCallback((size: number) => {
|
||||
setPageSizeState(size);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const goToPrev = useCallback(() => {
|
||||
setPage((prev) => Math.max(1, prev - 1));
|
||||
}, []);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
setPage((prev) => Math.min(totalPages, prev + 1));
|
||||
}, [totalPages]);
|
||||
|
||||
const setLoading = useCallback((isLoading: boolean, scope?: 'page' | 'all' | null) => {
|
||||
setLoadingState(isLoading);
|
||||
setLoadingScope(isLoading ? (scope ?? null) : null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pageSize,
|
||||
totalPages,
|
||||
currentPage,
|
||||
pageItems,
|
||||
setPageSize,
|
||||
goToPrev,
|
||||
goToNext,
|
||||
loading,
|
||||
loadingScope,
|
||||
setLoading
|
||||
};
|
||||
};
|
||||
|
||||
interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
|
||||
config: QuotaConfig<TState, TData>;
|
||||
files: AuthFileItem[];
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function QuotaSection<TState extends QuotaStatusState, TData>({
|
||||
config,
|
||||
files,
|
||||
loading,
|
||||
disabled
|
||||
}: QuotaSectionProps<TState, TData>) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||
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
|
||||
]);
|
||||
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
|
||||
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
|
||||
|
||||
const {
|
||||
pageSize,
|
||||
totalPages,
|
||||
currentPage,
|
||||
pageItems,
|
||||
setPageSize,
|
||||
goToPrev,
|
||||
goToNext,
|
||||
loading: sectionLoading,
|
||||
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 pendingQuotaRefreshRef = useRef(false);
|
||||
const prevFilesLoadingRef = useRef(loading);
|
||||
|
||||
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;
|
||||
if (filteredFiles.length === 0) {
|
||||
setQuota({});
|
||||
return;
|
||||
}
|
||||
setQuota((prev) => {
|
||||
const nextState: Record<string, TState> = {};
|
||||
filteredFiles.forEach((file) => {
|
||||
const cached = prev[file.name];
|
||||
if (cached) {
|
||||
nextState[file.name] = cached;
|
||||
}
|
||||
});
|
||||
return nextState;
|
||||
});
|
||||
}, [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={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={handleRefresh}
|
||||
disabled={disabled || isRefreshing}
|
||||
loading={isRefreshing}
|
||||
title={t('quota_management.refresh_files_and_quota')}
|
||||
aria-label={t('quota_management.refresh_files_and_quota')}
|
||||
>
|
||||
{!isRefreshing && <IconRefreshCw size={16} />}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{filteredFiles.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t(`${config.i18nPrefix}.empty_title`)}
|
||||
description={t(`${config.i18nPrefix}.empty_desc`)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div ref={gridRef} className={config.gridClassName}>
|
||||
{pageItems.map((item) => (
|
||||
<QuotaCard
|
||||
key={item.name}
|
||||
item={item}
|
||||
quota={quota[item.name]}
|
||||
resolvedTheme={resolvedTheme}
|
||||
i18nPrefix={config.i18nPrefix}
|
||||
cardClassName={config.cardClassName}
|
||||
defaultType={config.type}
|
||||
renderQuotaItems={config.renderQuotaItems}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={goToPrev}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
{t('auth_files.pagination_prev')}
|
||||
</Button>
|
||||
<div className={styles.pageInfo}>
|
||||
{t('auth_files.pagination_info', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
count: filteredFiles.length
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={goToNext}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
{t('auth_files.pagination_next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
9
src/components/quota/index.ts
Normal file
9
src/components/quota/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Quota components barrel export.
|
||||
*/
|
||||
|
||||
export { QuotaSection } from './QuotaSection';
|
||||
export { QuotaCard } from './QuotaCard';
|
||||
export { useQuotaLoader } from './useQuotaLoader';
|
||||
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
|
||||
export type { QuotaConfig } from './quotaConfigs';
|
||||
553
src/components/quota/quotaConfigs.ts
Normal file
553
src/components/quota/quotaConfigs.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* Quota configuration definitions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type {
|
||||
AntigravityQuotaGroup,
|
||||
AntigravityModelsPayload,
|
||||
AntigravityQuotaState,
|
||||
AuthFileItem,
|
||||
CodexQuotaState,
|
||||
CodexUsageWindow,
|
||||
CodexQuotaWindow,
|
||||
CodexUsagePayload,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState,
|
||||
GeminiCliQuotaState
|
||||
} from '@/types';
|
||||
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
|
||||
import {
|
||||
ANTIGRAVITY_QUOTA_URLS,
|
||||
ANTIGRAVITY_REQUEST_HEADERS,
|
||||
CODEX_USAGE_URL,
|
||||
CODEX_REQUEST_HEADERS,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
GEMINI_CLI_REQUEST_HEADERS,
|
||||
normalizeAuthIndexValue,
|
||||
normalizeNumberValue,
|
||||
normalizePlanType,
|
||||
normalizeQuotaFraction,
|
||||
normalizeStringValue,
|
||||
parseAntigravityPayload,
|
||||
parseCodexUsagePayload,
|
||||
parseGeminiCliQuotaPayload,
|
||||
resolveCodexChatgptAccountId,
|
||||
resolveCodexPlanType,
|
||||
resolveGeminiCliProjectId,
|
||||
formatCodexResetLabel,
|
||||
formatQuotaResetTime,
|
||||
buildAntigravityQuotaGroups,
|
||||
buildGeminiCliQuotaBuckets,
|
||||
createStatusError,
|
||||
getStatusFromError,
|
||||
isAntigravityFile,
|
||||
isCodexFile,
|
||||
isGeminiCliFile,
|
||||
isRuntimeOnlyAuthFile
|
||||
} from '@/utils/quota';
|
||||
import type { QuotaRenderHelpers } from './QuotaCard';
|
||||
import styles from '@/pages/QuotaPage.module.scss';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
|
||||
|
||||
export interface QuotaStore {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||
clearQuotaCache: () => void;
|
||||
}
|
||||
|
||||
export interface QuotaConfig<TState, TData> {
|
||||
type: QuotaType;
|
||||
i18nPrefix: string;
|
||||
filterFn: (file: AuthFileItem) => boolean;
|
||||
fetchQuota: (file: AuthFileItem, t: TFunction) => Promise<TData>;
|
||||
storeSelector: (state: QuotaStore) => Record<string, TState>;
|
||||
storeSetter: keyof QuotaStore;
|
||||
buildLoadingState: () => TState;
|
||||
buildSuccessState: (data: TData) => TState;
|
||||
buildErrorState: (message: string, status?: number) => TState;
|
||||
cardClassName: string;
|
||||
controlsClassName: string;
|
||||
controlClassName: string;
|
||||
gridClassName: string;
|
||||
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
|
||||
}
|
||||
|
||||
const fetchAntigravityQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<AntigravityQuotaGroup[]> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('antigravity_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
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: '{}'
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
lastError = getApiCallErrorMessage(result);
|
||||
lastStatus = result.statusCode;
|
||||
if (result.statusCode === 403 || result.statusCode === 404) {
|
||||
priorityStatus ??= result.statusCode;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hadSuccess) {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
|
||||
};
|
||||
|
||||
const buildCodexQuotaWindows = (payload: CodexUsagePayload, t: TFunction): CodexQuotaWindow[] => {
|
||||
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
||||
const codeReviewLimit = payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
||||
const windows: CodexQuotaWindow[] = [];
|
||||
|
||||
const addWindow = (
|
||||
id: string,
|
||||
labelKey: string,
|
||||
window?: CodexUsageWindow | null,
|
||||
limitReached?: boolean,
|
||||
allowed?: boolean
|
||||
) => {
|
||||
if (!window) return;
|
||||
const resetLabel = formatCodexResetLabel(window);
|
||||
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
|
||||
const isLimitReached = Boolean(limitReached) || allowed === false;
|
||||
const usedPercent = usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
||||
windows.push({
|
||||
id,
|
||||
label: t(labelKey),
|
||||
labelKey,
|
||||
usedPercent,
|
||||
resetLabel
|
||||
});
|
||||
};
|
||||
|
||||
addWindow(
|
||||
'primary',
|
||||
'codex_quota.primary_window',
|
||||
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||
rateLimit?.allowed
|
||||
);
|
||||
addWindow(
|
||||
'secondary',
|
||||
'codex_quota.secondary_window',
|
||||
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||
rateLimit?.allowed
|
||||
);
|
||||
addWindow(
|
||||
'code-review',
|
||||
'codex_quota.code_review_window',
|
||||
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
||||
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
||||
codeReviewLimit?.allowed
|
||||
);
|
||||
|
||||
return windows;
|
||||
};
|
||||
|
||||
const fetchCodexQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('codex_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const planTypeFromFile = resolveCodexPlanType(file);
|
||||
const accountId = resolveCodexChatgptAccountId(file);
|
||||
if (!accountId) {
|
||||
throw new Error(t('codex_quota.missing_account_id'));
|
||||
}
|
||||
|
||||
const requestHeader: Record<string, string> = {
|
||||
...CODEX_REQUEST_HEADERS,
|
||||
'Chatgpt-Account-Id': accountId
|
||||
};
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'GET',
|
||||
url: CODEX_USAGE_URL,
|
||||
header: requestHeader
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
|
||||
if (!payload) {
|
||||
throw new Error(t('codex_quota.empty_windows'));
|
||||
}
|
||||
|
||||
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
|
||||
const windows = buildCodexQuotaWindows(payload, t);
|
||||
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
|
||||
};
|
||||
|
||||
const fetchGeminiCliQuota = async (
|
||||
file: AuthFileItem,
|
||||
t: TFunction
|
||||
): Promise<GeminiCliQuotaBucketState[]> => {
|
||||
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
||||
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
||||
if (!authIndex) {
|
||||
throw new Error(t('gemini_cli_quota.missing_auth_index'));
|
||||
}
|
||||
|
||||
const projectId = resolveGeminiCliProjectId(file);
|
||||
if (!projectId) {
|
||||
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
authIndex,
|
||||
method: 'POST',
|
||||
url: GEMINI_CLI_QUOTA_URL,
|
||||
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
||||
data: JSON.stringify({ project: projectId })
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
||||
}
|
||||
|
||||
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
||||
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
||||
if (buckets.length === 0) return [];
|
||||
|
||||
const parsedBuckets = buckets
|
||||
.map((bucket) => {
|
||||
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
||||
if (!modelId) return null;
|
||||
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
||||
const remainingFractionRaw = normalizeQuotaFraction(
|
||||
bucket.remainingFraction ?? bucket.remaining_fraction
|
||||
);
|
||||
const remainingAmount = normalizeNumberValue(bucket.remainingAmount ?? bucket.remaining_amount);
|
||||
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
||||
let fallbackFraction: number | null = null;
|
||||
if (remainingAmount !== null) {
|
||||
fallbackFraction = remainingAmount <= 0 ? 0 : null;
|
||||
} else if (resetTime) {
|
||||
fallbackFraction = 0;
|
||||
}
|
||||
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
|
||||
return {
|
||||
modelId,
|
||||
tokenType,
|
||||
remainingFraction,
|
||||
remainingAmount,
|
||||
resetTime
|
||||
};
|
||||
})
|
||||
.filter((bucket): bucket is GeminiCliParsedBucket => bucket !== null);
|
||||
|
||||
return buildGeminiCliQuotaBuckets(parsedBuckets);
|
||||
};
|
||||
|
||||
const renderAntigravityItems = (
|
||||
quota: AntigravityQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h } = React;
|
||||
const groups = quota.groups ?? [];
|
||||
|
||||
if (groups.length === 0) {
|
||||
return h('div', { className: styleMap.quotaMessage }, t('antigravity_quota.empty_models'));
|
||||
}
|
||||
|
||||
return groups.map((group) => {
|
||||
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
|
||||
const percent = Math.round(clamped * 100);
|
||||
const resetLabel = formatQuotaResetTime(group.resetTime);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: group.id, className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h(
|
||||
'span',
|
||||
{ className: styleMap.quotaModel, title: group.models.join(', ') },
|
||||
group.label
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, `${percent}%`),
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderCodexItems = (
|
||||
quota: CodexQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h, Fragment } = React;
|
||||
const windows = quota.windows ?? [];
|
||||
const planType = quota.planType ?? null;
|
||||
|
||||
const getPlanLabel = (pt?: string | null): string | null => {
|
||||
const normalized = normalizePlanType(pt);
|
||||
if (!normalized) return null;
|
||||
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
||||
if (normalized === 'team') return t('codex_quota.plan_team');
|
||||
if (normalized === 'free') return t('codex_quota.plan_free');
|
||||
return pt || normalized;
|
||||
};
|
||||
|
||||
const planLabel = getPlanLabel(planType);
|
||||
const isFreePlan = normalizePlanType(planType) === 'free';
|
||||
const nodes: ReactNode[] = [];
|
||||
|
||||
if (planLabel) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'plan', className: styleMap.codexPlan },
|
||||
h('span', { className: styleMap.codexPlanLabel }, t('codex_quota.plan_label')),
|
||||
h('span', { className: styleMap.codexPlanValue }, planLabel)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isFreePlan) {
|
||||
nodes.push(
|
||||
h(
|
||||
'div',
|
||||
{ key: 'warning', className: styleMap.quotaWarning },
|
||||
t('codex_quota.no_access')
|
||||
)
|
||||
);
|
||||
return h(Fragment, null, ...nodes);
|
||||
}
|
||||
|
||||
if (windows.length === 0) {
|
||||
nodes.push(
|
||||
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('codex_quota.empty_windows'))
|
||||
);
|
||||
return h(Fragment, null, ...nodes);
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
...windows.map((window) => {
|
||||
const used = window.usedPercent;
|
||||
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
|
||||
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
|
||||
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
|
||||
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: window.id, className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel }, windowLabel),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||
h('span', { className: styleMap.quotaReset }, window.resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return h(Fragment, null, ...nodes);
|
||||
};
|
||||
|
||||
const renderGeminiCliItems = (
|
||||
quota: GeminiCliQuotaState,
|
||||
t: TFunction,
|
||||
helpers: QuotaRenderHelpers
|
||||
): ReactNode => {
|
||||
const { styles: styleMap, QuotaProgressBar } = helpers;
|
||||
const { createElement: h } = React;
|
||||
const buckets = quota.buckets ?? [];
|
||||
|
||||
if (buckets.length === 0) {
|
||||
return h('div', { className: styleMap.quotaMessage }, t('gemini_cli_quota.empty_buckets'));
|
||||
}
|
||||
|
||||
return buckets.map((bucket) => {
|
||||
const fraction = bucket.remainingFraction;
|
||||
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
||||
const percent = clamped === null ? null : Math.round(clamped * 100);
|
||||
const percentLabel = percent === null ? '--' : `${percent}%`;
|
||||
const remainingAmountLabel =
|
||||
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
||||
? null
|
||||
: t('gemini_cli_quota.remaining_amount', {
|
||||
count: bucket.remainingAmount
|
||||
});
|
||||
const titleBase =
|
||||
bucket.modelIds && bucket.modelIds.length > 0 ? bucket.modelIds.join(', ') : bucket.label;
|
||||
const title = bucket.tokenType ? `${titleBase} (${bucket.tokenType})` : titleBase;
|
||||
|
||||
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{ key: bucket.id, className: styleMap.quotaRow },
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaRowHeader },
|
||||
h('span', { className: styleMap.quotaModel, title }, bucket.label),
|
||||
h(
|
||||
'div',
|
||||
{ className: styleMap.quotaMeta },
|
||||
h('span', { className: styleMap.quotaPercent }, percentLabel),
|
||||
remainingAmountLabel
|
||||
? h('span', { className: styleMap.quotaAmount }, remainingAmountLabel)
|
||||
: null,
|
||||
h('span', { className: styleMap.quotaReset }, resetLabel)
|
||||
)
|
||||
),
|
||||
h(QuotaProgressBar, { percent, highThreshold: 60, mediumThreshold: 20 })
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
|
||||
type: 'antigravity',
|
||||
i18nPrefix: 'antigravity_quota',
|
||||
filterFn: (file) => isAntigravityFile(file),
|
||||
fetchQuota: fetchAntigravityQuota,
|
||||
storeSelector: (state) => state.antigravityQuota,
|
||||
storeSetter: 'setAntigravityQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', groups: [] }),
|
||||
buildSuccessState: (groups) => ({ status: 'success', groups }),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
groups: [],
|
||||
error: message,
|
||||
errorStatus: status
|
||||
}),
|
||||
cardClassName: styles.antigravityCard,
|
||||
controlsClassName: styles.antigravityControls,
|
||||
controlClassName: styles.antigravityControl,
|
||||
gridClassName: styles.antigravityGrid,
|
||||
renderQuotaItems: renderAntigravityItems
|
||||
};
|
||||
|
||||
export const CODEX_CONFIG: QuotaConfig<
|
||||
CodexQuotaState,
|
||||
{ planType: string | null; windows: CodexQuotaWindow[] }
|
||||
> = {
|
||||
type: 'codex',
|
||||
i18nPrefix: 'codex_quota',
|
||||
filterFn: (file) => isCodexFile(file),
|
||||
fetchQuota: fetchCodexQuota,
|
||||
storeSelector: (state) => state.codexQuota,
|
||||
storeSetter: 'setCodexQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', windows: [] }),
|
||||
buildSuccessState: (data) => ({
|
||||
status: 'success',
|
||||
windows: data.windows,
|
||||
planType: data.planType
|
||||
}),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
windows: [],
|
||||
error: message,
|
||||
errorStatus: status
|
||||
}),
|
||||
cardClassName: styles.codexCard,
|
||||
controlsClassName: styles.codexControls,
|
||||
controlClassName: styles.codexControl,
|
||||
gridClassName: styles.codexGrid,
|
||||
renderQuotaItems: renderCodexItems
|
||||
};
|
||||
|
||||
export const GEMINI_CLI_CONFIG: QuotaConfig<GeminiCliQuotaState, GeminiCliQuotaBucketState[]> = {
|
||||
type: 'gemini-cli',
|
||||
i18nPrefix: 'gemini_cli_quota',
|
||||
filterFn: (file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file),
|
||||
fetchQuota: fetchGeminiCliQuota,
|
||||
storeSelector: (state) => state.geminiCliQuota,
|
||||
storeSetter: 'setGeminiCliQuota',
|
||||
buildLoadingState: () => ({ status: 'loading', buckets: [] }),
|
||||
buildSuccessState: (buckets) => ({ status: 'success', buckets }),
|
||||
buildErrorState: (message, status) => ({
|
||||
status: 'error',
|
||||
buckets: [],
|
||||
error: message,
|
||||
errorStatus: status
|
||||
}),
|
||||
cardClassName: styles.geminiCliCard,
|
||||
controlsClassName: styles.geminiCliControls,
|
||||
controlClassName: styles.geminiCliControl,
|
||||
gridClassName: styles.geminiCliGrid,
|
||||
renderQuotaItems: renderGeminiCliItems
|
||||
};
|
||||
40
src/components/quota/useGridColumns.ts
Normal file
40
src/components/quota/useGridColumns.ts
Normal 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];
|
||||
}
|
||||
98
src/components/quota/useQuotaLoader.ts
Normal file
98
src/components/quota/useQuotaLoader.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Generic hook for quota data fetching and management.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import { useQuotaStore } from '@/stores';
|
||||
import { getStatusFromError } from '@/utils/quota';
|
||||
import type { QuotaConfig } from './quotaConfigs';
|
||||
|
||||
type QuotaScope = 'page' | 'all';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
|
||||
|
||||
interface LoadQuotaResult<TData> {
|
||||
name: string;
|
||||
status: 'success' | 'error';
|
||||
data?: TData;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
export function useQuotaLoader<TState, TData>(config: QuotaConfig<TState, TData>) {
|
||||
const { t } = useTranslation();
|
||||
const quota = useQuotaStore(config.storeSelector);
|
||||
const setQuota = useQuotaStore((state) => state[config.storeSetter]) as QuotaSetter<
|
||||
Record<string, TState>
|
||||
>;
|
||||
|
||||
const loadingRef = useRef(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const loadQuota = useCallback(
|
||||
async (
|
||||
targets: AuthFileItem[],
|
||||
scope: QuotaScope,
|
||||
setLoading: (loading: boolean, scope?: QuotaScope | null) => void
|
||||
) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
const requestId = ++requestIdRef.current;
|
||||
setLoading(true, scope);
|
||||
|
||||
try {
|
||||
if (targets.length === 0) return;
|
||||
|
||||
setQuota((prev) => {
|
||||
const nextState = { ...prev };
|
||||
targets.forEach((file) => {
|
||||
nextState[file.name] = config.buildLoadingState();
|
||||
});
|
||||
return nextState;
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
targets.map(async (file): Promise<LoadQuotaResult<TData>> => {
|
||||
try {
|
||||
const data = await config.fetchQuota(file, t);
|
||||
return { name: file.name, status: 'success', data };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
||||
const errorStatus = getStatusFromError(err);
|
||||
return { name: file.name, status: 'error', error: message, errorStatus };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
setQuota((prev) => {
|
||||
const nextState = { ...prev };
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'success') {
|
||||
nextState[result.name] = config.buildSuccessState(result.data as TData);
|
||||
} else {
|
||||
nextState[result.name] = config.buildErrorState(
|
||||
result.error || t('common.unknown_error'),
|
||||
result.errorStatus
|
||||
);
|
||||
}
|
||||
});
|
||||
return nextState;
|
||||
});
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[config, setQuota, t]
|
||||
);
|
||||
|
||||
return { quota, loadQuota };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconX } from './icons';
|
||||
|
||||
interface ModalProps {
|
||||
@@ -9,27 +10,117 @@ interface ModalProps {
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 350;
|
||||
const MODAL_LOCK_CLASS = 'modal-open';
|
||||
let activeModalCount = 0;
|
||||
|
||||
const lockScroll = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (activeModalCount === 0) {
|
||||
document.body?.classList.add(MODAL_LOCK_CLASS);
|
||||
document.documentElement?.classList.add(MODAL_LOCK_CLASS);
|
||||
}
|
||||
activeModalCount += 1;
|
||||
};
|
||||
|
||||
const unlockScroll = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
activeModalCount = Math.max(0, activeModalCount - 1);
|
||||
if (activeModalCount === 0) {
|
||||
document.body?.classList.remove(MODAL_LOCK_CLASS);
|
||||
document.documentElement?.classList.remove(MODAL_LOCK_CLASS);
|
||||
}
|
||||
};
|
||||
|
||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||
if (!open) return null;
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMaskClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
const startClose = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
if (closeTimerRef.current !== null) return;
|
||||
setIsClosing(true);
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setIsClosing(false);
|
||||
closeTimerRef.current = null;
|
||||
if (notifyParent) {
|
||||
onClose();
|
||||
}
|
||||
}, CLOSE_ANIMATION_DURATION);
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (open) {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setIsVisible(true);
|
||||
setIsClosing(false);
|
||||
});
|
||||
} else if (isVisible) {
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
startClose(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleMaskClick}>
|
||||
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, isVisible, startClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
startClose(true);
|
||||
}, [startClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shouldLockScroll = open || isVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLockScroll) return;
|
||||
lockScroll();
|
||||
return () => unlockScroll();
|
||||
}, [shouldLockScroll]);
|
||||
|
||||
if (!open && !isVisible) return null;
|
||||
|
||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
||||
|
||||
const modalContent = (
|
||||
<div className={overlayClass}>
|
||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">{title}</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="modal-footer">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return modalContent;
|
||||
}
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
|
||||
79
src/components/usage/ApiDetailsCard.tsx
Normal file
79
src/components/usage/ApiDetailsCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ApiDetailsCardProps {
|
||||
apiStats: ApiStats[];
|
||||
loading: boolean;
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = (endpoint: string) => {
|
||||
setExpandedApis((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(endpoint)) {
|
||||
newSet.delete(endpoint);
|
||||
} else {
|
||||
newSet.add(endpoint);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.api_details')}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : apiStats.length > 0 ? (
|
||||
<div className={styles.apiList}>
|
||||
{apiStats.map((api) => (
|
||||
<div key={api.endpoint} className={styles.apiItem}>
|
||||
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
|
||||
<div className={styles.apiInfo}>
|
||||
<span className={styles.apiEndpoint}>{api.endpoint}</span>
|
||||
<div className={styles.apiStats}>
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.requests_count')}: {api.totalRequests}
|
||||
</span>
|
||||
<span className={styles.apiBadge}>
|
||||
Tokens: {formatTokensInMillions(api.totalTokens)}
|
||||
</span>
|
||||
{hasPrices && api.totalCost > 0 && (
|
||||
<span className={styles.apiBadge}>
|
||||
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.expandIcon}>
|
||||
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
{expandedApis.has(api.endpoint) && (
|
||||
<div className={styles.apiModels}>
|
||||
{Object.entries(api.models).map(([model, stats]) => (
|
||||
<div key={model} className={styles.modelRow}>
|
||||
<span className={styles.modelName}>{model}</span>
|
||||
<span className={styles.modelStat}>
|
||||
{stats.requests} {t('usage_stats.requests_count')}
|
||||
</span>
|
||||
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
92
src/components/usage/ChartLineSelector.tsx
Normal file
92
src/components/usage/ChartLineSelector.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ChartLineSelectorProps {
|
||||
chartLines: string[];
|
||||
modelNames: string[];
|
||||
maxLines?: number;
|
||||
onChange: (lines: string[]) => void;
|
||||
}
|
||||
|
||||
export function ChartLineSelector({
|
||||
chartLines,
|
||||
modelNames,
|
||||
maxLines = 9,
|
||||
onChange
|
||||
}: ChartLineSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAdd = () => {
|
||||
if (chartLines.length >= maxLines) return;
|
||||
const unusedModel = modelNames.find((m) => !chartLines.includes(m));
|
||||
if (unusedModel) {
|
||||
onChange([...chartLines, unusedModel]);
|
||||
} else {
|
||||
onChange([...chartLines, 'all']);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
if (chartLines.length <= 1) return;
|
||||
const newLines = [...chartLines];
|
||||
newLines.splice(index, 1);
|
||||
onChange(newLines);
|
||||
};
|
||||
|
||||
const handleChange = (index: number, value: string) => {
|
||||
const newLines = [...chartLines];
|
||||
newLines[index] = value;
|
||||
onChange(newLines);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t('usage_stats.chart_line_actions_label')}
|
||||
extra={
|
||||
<div className={styles.chartLineHeader}>
|
||||
<span className={styles.chartLineCount}>
|
||||
{chartLines.length}/{maxLines}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
disabled={chartLines.length >= maxLines}
|
||||
>
|
||||
{t('usage_stats.chart_line_add')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.chartLineList}>
|
||||
{chartLines.map((line, index) => (
|
||||
<div key={index} className={styles.chartLineItem}>
|
||||
<span className={styles.chartLineLabel}>
|
||||
{t(`usage_stats.chart_line_label_${index + 1}`)}
|
||||
</span>
|
||||
<select
|
||||
value={line}
|
||||
onChange={(e) => handleChange(index, e.target.value)}
|
||||
className={styles.select}
|
||||
>
|
||||
<option value="all">{t('usage_stats.chart_line_all')}</option>
|
||||
{modelNames.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{chartLines.length > 1 && (
|
||||
<Button variant="danger" size="sm" onClick={() => handleRemove(index)}>
|
||||
{t('usage_stats.chart_line_delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.chartLineHint}>{t('usage_stats.chart_line_hint')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
54
src/components/usage/ModelStatsCard.tsx
Normal file
54
src/components/usage/ModelStatsCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface ModelStat {
|
||||
model: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface ModelStatsCardProps {
|
||||
modelStats: ModelStat[];
|
||||
loading: boolean;
|
||||
hasPrices: boolean;
|
||||
}
|
||||
|
||||
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.models')}>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : modelStats.length > 0 ? (
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('usage_stats.model_name')}</th>
|
||||
<th>{t('usage_stats.requests_count')}</th>
|
||||
<th>{t('usage_stats.tokens_count')}</th>
|
||||
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modelStats.map((stat) => (
|
||||
<tr key={stat.model}>
|
||||
<td className={styles.modelCell}>{stat.model}</td>
|
||||
<td>{stat.requests.toLocaleString()}</td>
|
||||
<td>{formatTokensInMillions(stat.tokens)}</td>
|
||||
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
164
src/components/usage/PriceSettingsCard.tsx
Normal file
164
src/components/usage/PriceSettingsCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { ModelPrice } from '@/utils/usage';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface PriceSettingsCardProps {
|
||||
modelNames: string[];
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
onPricesChange: (prices: Record<string, ModelPrice>) => void;
|
||||
}
|
||||
|
||||
export function PriceSettingsCard({
|
||||
modelNames,
|
||||
modelPrices,
|
||||
onPricesChange
|
||||
}: PriceSettingsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [promptPrice, setPromptPrice] = useState('');
|
||||
const [completionPrice, setCompletionPrice] = useState('');
|
||||
const [cachePrice, setCachePrice] = useState('');
|
||||
|
||||
const handleSavePrice = () => {
|
||||
if (!selectedModel) return;
|
||||
const prompt = parseFloat(promptPrice) || 0;
|
||||
const completion = parseFloat(completionPrice) || 0;
|
||||
const cache = cachePrice.trim() === '' ? prompt : parseFloat(cachePrice) || 0;
|
||||
const newPrices = { ...modelPrices, [selectedModel]: { prompt, completion, cache } };
|
||||
onPricesChange(newPrices);
|
||||
setSelectedModel('');
|
||||
setPromptPrice('');
|
||||
setCompletionPrice('');
|
||||
setCachePrice('');
|
||||
};
|
||||
|
||||
const handleDeletePrice = (model: string) => {
|
||||
const newPrices = { ...modelPrices };
|
||||
delete newPrices[model];
|
||||
onPricesChange(newPrices);
|
||||
};
|
||||
|
||||
const handleEditPrice = (model: string) => {
|
||||
const price = modelPrices[model];
|
||||
setSelectedModel(model);
|
||||
setPromptPrice(price?.prompt?.toString() || '');
|
||||
setCompletionPrice(price?.completion?.toString() || '');
|
||||
setCachePrice(price?.cache?.toString() || '');
|
||||
};
|
||||
|
||||
const handleModelSelect = (value: string) => {
|
||||
setSelectedModel(value);
|
||||
const price = modelPrices[value];
|
||||
if (price) {
|
||||
setPromptPrice(price.prompt.toString());
|
||||
setCompletionPrice(price.completion.toString());
|
||||
setCachePrice(price.cache.toString());
|
||||
} else {
|
||||
setPromptPrice('');
|
||||
setCompletionPrice('');
|
||||
setCachePrice('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title={t('usage_stats.model_price_settings')}>
|
||||
<div className={styles.pricingSection}>
|
||||
{/* Price Form */}
|
||||
<div className={styles.priceForm}>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_name')}</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => handleModelSelect(e.target.value)}
|
||||
className={styles.select}
|
||||
>
|
||||
<option value="">{t('usage_stats.model_price_select_placeholder')}</option>
|
||||
{modelNames.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_prompt')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={promptPrice}
|
||||
onChange={(e) => setPromptPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_completion')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={completionPrice}
|
||||
onChange={(e) => setCompletionPrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label>{t('usage_stats.model_price_cache')} ($/1M)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={cachePrice}
|
||||
onChange={(e) => setCachePrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
step="0.0001"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleSavePrice} disabled={!selectedModel}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved Prices List */}
|
||||
<div className={styles.pricesList}>
|
||||
<h4 className={styles.pricesTitle}>{t('usage_stats.saved_prices')}</h4>
|
||||
{Object.keys(modelPrices).length > 0 ? (
|
||||
<div className={styles.pricesGrid}>
|
||||
{Object.entries(modelPrices).map(([model, price]) => (
|
||||
<div key={model} className={styles.priceItem}>
|
||||
<div className={styles.priceInfo}>
|
||||
<span className={styles.priceModel}>{model}</span>
|
||||
<div className={styles.priceMeta}>
|
||||
<span>
|
||||
{t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M
|
||||
</span>
|
||||
<span>
|
||||
{t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M
|
||||
</span>
|
||||
<span>
|
||||
{t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.priceActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleEditPrice(model)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDeletePrice(model)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{t('usage_stats.model_price_empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
184
src/components/usage/StatCards.tsx
Normal file
184
src/components/usage/StatCards.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
|
||||
import {
|
||||
formatTokensInMillions,
|
||||
formatPerMinuteValue,
|
||||
formatUsd,
|
||||
calculateTokenBreakdown,
|
||||
calculateRecentPerMinuteRates,
|
||||
calculateTotalCost,
|
||||
type ModelPrice
|
||||
} from '@/utils/usage';
|
||||
import { sparklineOptions } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './hooks/useUsageData';
|
||||
import type { SparklineBundle } from './hooks/useSparklines';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
interface StatCardData {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
accent: string;
|
||||
accentSoft: string;
|
||||
accentBorder: string;
|
||||
value: string;
|
||||
meta?: ReactNode;
|
||||
trend: SparklineBundle | null;
|
||||
}
|
||||
|
||||
export interface StatCardsProps {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
sparklines: {
|
||||
requests: SparklineBundle | null;
|
||||
tokens: SparklineBundle | null;
|
||||
rpm: SparklineBundle | null;
|
||||
tpm: SparklineBundle | null;
|
||||
cost: SparklineBundle | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function StatCards({ usage, loading, modelPrices, sparklines }: StatCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
|
||||
const rateStats = usage
|
||||
? calculateRecentPerMinuteRates(30, usage)
|
||||
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
|
||||
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
|
||||
const hasPrices = Object.keys(modelPrices).length > 0;
|
||||
|
||||
const statsCards: StatCardData[] = [
|
||||
{
|
||||
key: 'requests',
|
||||
label: t('usage_stats.total_requests'),
|
||||
icon: <IconSatellite size={16} />,
|
||||
accent: '#3b82f6',
|
||||
accentSoft: 'rgba(59, 130, 246, 0.18)',
|
||||
accentBorder: 'rgba(59, 130, 246, 0.35)',
|
||||
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
<span className={styles.statMetaDot} style={{ backgroundColor: '#10b981' }} />
|
||||
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
|
||||
</span>
|
||||
<span className={styles.statMetaItem}>
|
||||
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
|
||||
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
trend: sparklines.requests
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
label: t('usage_stats.total_tokens'),
|
||||
icon: <IconDiamond size={16} />,
|
||||
accent: '#8b5cf6',
|
||||
accentSoft: 'rgba(139, 92, 246, 0.18)',
|
||||
accentBorder: 'rgba(139, 92, 246, 0.35)',
|
||||
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
|
||||
</span>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
trend: sparklines.tokens
|
||||
},
|
||||
{
|
||||
key: 'rpm',
|
||||
label: t('usage_stats.rpm_30m'),
|
||||
icon: <IconTimer size={16} />,
|
||||
accent: '#22c55e',
|
||||
accentSoft: 'rgba(34, 197, 94, 0.18)',
|
||||
accentBorder: 'rgba(34, 197, 94, 0.32)',
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.rpm
|
||||
},
|
||||
{
|
||||
key: 'tpm',
|
||||
label: t('usage_stats.tpm_30m'),
|
||||
icon: <IconTrendingUp size={16} />,
|
||||
accent: '#f97316',
|
||||
accentSoft: 'rgba(249, 115, 22, 0.18)',
|
||||
accentBorder: 'rgba(249, 115, 22, 0.32)',
|
||||
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
|
||||
meta: (
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
|
||||
</span>
|
||||
),
|
||||
trend: sparklines.tpm
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
label: t('usage_stats.total_cost'),
|
||||
icon: <IconDollarSign size={16} />,
|
||||
accent: '#f59e0b',
|
||||
accentSoft: 'rgba(245, 158, 11, 0.18)',
|
||||
accentBorder: 'rgba(245, 158, 11, 0.32)',
|
||||
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
|
||||
meta: (
|
||||
<>
|
||||
<span className={styles.statMetaItem}>
|
||||
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
|
||||
</span>
|
||||
{!hasPrices && (
|
||||
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
|
||||
{t('usage_stats.cost_need_price')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
trend: hasPrices ? sparklines.cost : null
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.statsGrid}>
|
||||
{statsCards.map((card) => (
|
||||
<div
|
||||
key={card.key}
|
||||
className={styles.statCard}
|
||||
style={
|
||||
{
|
||||
'--accent': card.accent,
|
||||
'--accent-soft': card.accentSoft,
|
||||
'--accent-border': card.accentBorder
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={styles.statCardHeader}>
|
||||
<div className={styles.statLabelGroup}>
|
||||
<span className={styles.statLabel}>{card.label}</span>
|
||||
</div>
|
||||
<span className={styles.statIconBadge}>{card.icon}</span>
|
||||
</div>
|
||||
<div className={styles.statValue}>{card.value}</div>
|
||||
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
|
||||
<div className={styles.statTrend}>
|
||||
{card.trend ? (
|
||||
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
|
||||
) : (
|
||||
<div className={styles.statTrendPlaceholder}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/components/usage/UsageChart.tsx
Normal file
91
src/components/usage/UsageChart.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ChartOptions } from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { ChartData } from '@/utils/usage';
|
||||
import { getHourChartMinWidth } from '@/utils/usage/chartConfig';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
export interface UsageChartProps {
|
||||
title: string;
|
||||
period: 'hour' | 'day';
|
||||
onPeriodChange: (period: 'hour' | 'day') => void;
|
||||
chartData: ChartData;
|
||||
chartOptions: ChartOptions<'line'>;
|
||||
loading: boolean;
|
||||
isMobile: boolean;
|
||||
emptyText: string;
|
||||
}
|
||||
|
||||
export function UsageChart({
|
||||
title,
|
||||
period,
|
||||
onPeriodChange,
|
||||
chartData,
|
||||
chartOptions,
|
||||
loading,
|
||||
isMobile,
|
||||
emptyText
|
||||
}: UsageChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
extra={
|
||||
<div className={styles.periodButtons}>
|
||||
<Button
|
||||
variant={period === 'hour' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => onPeriodChange('hour')}
|
||||
>
|
||||
{t('usage_stats.by_hour')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={period === 'day' ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => onPeriodChange('day')}
|
||||
>
|
||||
{t('usage_stats.by_day')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={styles.hint}>{t('common.loading')}</div>
|
||||
) : chartData.labels.length > 0 ? (
|
||||
<div className={styles.chartWrapper}>
|
||||
<div className={styles.chartLegend} aria-label="Chart legend">
|
||||
{chartData.datasets.map((dataset, index) => (
|
||||
<div
|
||||
key={`${dataset.label}-${index}`}
|
||||
className={styles.legendItem}
|
||||
title={dataset.label}
|
||||
>
|
||||
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
|
||||
<span className={styles.legendLabel}>{dataset.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.chartArea}>
|
||||
<div className={styles.chartScroller}>
|
||||
<div
|
||||
className={styles.chartCanvas}
|
||||
style={
|
||||
period === 'hour'
|
||||
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.hint}>{emptyText}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
8
src/components/usage/hooks/index.ts
Normal file
8
src/components/usage/hooks/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useUsageData } from './useUsageData';
|
||||
export type { UsagePayload, UseUsageDataReturn } from './useUsageData';
|
||||
|
||||
export { useSparklines } from './useSparklines';
|
||||
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './useSparklines';
|
||||
|
||||
export { useChartData } from './useChartData';
|
||||
export type { UseChartDataOptions, UseChartDataReturn } from './useChartData';
|
||||
76
src/components/usage/hooks/useChartData.ts
Normal file
76
src/components/usage/hooks/useChartData.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { ChartOptions } from 'chart.js';
|
||||
import { buildChartData, type ChartData } from '@/utils/usage';
|
||||
import { buildChartOptions } from '@/utils/usage/chartConfig';
|
||||
import type { UsagePayload } from './useUsageData';
|
||||
|
||||
export interface UseChartDataOptions {
|
||||
usage: UsagePayload | null;
|
||||
chartLines: string[];
|
||||
isDark: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export interface UseChartDataReturn {
|
||||
requestsPeriod: 'hour' | 'day';
|
||||
setRequestsPeriod: (period: 'hour' | 'day') => void;
|
||||
tokensPeriod: 'hour' | 'day';
|
||||
setTokensPeriod: (period: 'hour' | 'day') => void;
|
||||
requestsChartData: ChartData;
|
||||
tokensChartData: ChartData;
|
||||
requestsChartOptions: ChartOptions<'line'>;
|
||||
tokensChartOptions: ChartOptions<'line'>;
|
||||
}
|
||||
|
||||
export function useChartData({
|
||||
usage,
|
||||
chartLines,
|
||||
isDark,
|
||||
isMobile
|
||||
}: UseChartDataOptions): UseChartDataReturn {
|
||||
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
|
||||
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
|
||||
|
||||
const requestsChartData = useMemo(() => {
|
||||
if (!usage) return { labels: [], datasets: [] };
|
||||
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
|
||||
}, [usage, requestsPeriod, chartLines]);
|
||||
|
||||
const tokensChartData = useMemo(() => {
|
||||
if (!usage) return { labels: [], datasets: [] };
|
||||
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
|
||||
}, [usage, tokensPeriod, chartLines]);
|
||||
|
||||
const requestsChartOptions = useMemo(
|
||||
() =>
|
||||
buildChartOptions({
|
||||
period: requestsPeriod,
|
||||
labels: requestsChartData.labels,
|
||||
isDark,
|
||||
isMobile
|
||||
}),
|
||||
[requestsPeriod, requestsChartData.labels, isDark, isMobile]
|
||||
);
|
||||
|
||||
const tokensChartOptions = useMemo(
|
||||
() =>
|
||||
buildChartOptions({
|
||||
period: tokensPeriod,
|
||||
labels: tokensChartData.labels,
|
||||
isDark,
|
||||
isMobile
|
||||
}),
|
||||
[tokensPeriod, tokensChartData.labels, isDark, isMobile]
|
||||
);
|
||||
|
||||
return {
|
||||
requestsPeriod,
|
||||
setRequestsPeriod,
|
||||
tokensPeriod,
|
||||
setTokensPeriod,
|
||||
requestsChartData,
|
||||
tokensChartData,
|
||||
requestsChartOptions,
|
||||
tokensChartOptions
|
||||
};
|
||||
}
|
||||
138
src/components/usage/hooks/useSparklines.ts
Normal file
138
src/components/usage/hooks/useSparklines.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { collectUsageDetails, extractTotalTokens } from '@/utils/usage';
|
||||
import type { UsagePayload } from './useUsageData';
|
||||
|
||||
export interface SparklineData {
|
||||
labels: string[];
|
||||
datasets: [
|
||||
{
|
||||
data: number[];
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
fill: boolean;
|
||||
tension: number;
|
||||
pointRadius: number;
|
||||
borderWidth: number;
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export interface SparklineBundle {
|
||||
data: SparklineData;
|
||||
}
|
||||
|
||||
export interface UseSparklinesOptions {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export interface UseSparklinesReturn {
|
||||
requestsSparkline: SparklineBundle | null;
|
||||
tokensSparkline: SparklineBundle | null;
|
||||
rpmSparkline: SparklineBundle | null;
|
||||
tpmSparkline: SparklineBundle | null;
|
||||
costSparkline: SparklineBundle | null;
|
||||
}
|
||||
|
||||
export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSparklinesReturn {
|
||||
const buildLastHourSeries = useCallback(
|
||||
(metric: 'requests' | 'tokens'): { labels: string[]; data: number[] } => {
|
||||
if (!usage) return { labels: [], data: [] };
|
||||
const details = collectUsageDetails(usage);
|
||||
if (!details.length) return { labels: [], data: [] };
|
||||
|
||||
const windowMinutes = 60;
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMinutes * 60 * 1000;
|
||||
const buckets = new Array(windowMinutes).fill(0);
|
||||
|
||||
details.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
||||
return;
|
||||
}
|
||||
const minuteIndex = Math.min(
|
||||
windowMinutes - 1,
|
||||
Math.floor((timestamp - windowStart) / 60000)
|
||||
);
|
||||
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
|
||||
buckets[minuteIndex] += increment;
|
||||
});
|
||||
|
||||
const labels = buckets.map((_, idx) => {
|
||||
const date = new Date(windowStart + (idx + 1) * 60000);
|
||||
const h = date.getHours().toString().padStart(2, '0');
|
||||
const m = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
});
|
||||
|
||||
return { labels, data: buckets };
|
||||
},
|
||||
[usage]
|
||||
);
|
||||
|
||||
const buildSparkline = useCallback(
|
||||
(
|
||||
series: { labels: string[]; data: number[] },
|
||||
color: string,
|
||||
backgroundColor: string
|
||||
): SparklineBundle | null => {
|
||||
if (loading || !series?.data?.length) {
|
||||
return null;
|
||||
}
|
||||
const sliceStart = Math.max(series.data.length - 60, 0);
|
||||
const labels = series.labels.slice(sliceStart);
|
||||
const points = series.data.slice(sliceStart);
|
||||
return {
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data: points,
|
||||
borderColor: color,
|
||||
backgroundColor,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
},
|
||||
[loading]
|
||||
);
|
||||
|
||||
const requestsSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('requests'), '#3b82f6', 'rgba(59, 130, 246, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
const tokensSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
const rpmSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
const tpmSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
const costSparkline = useMemo(
|
||||
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.18)'),
|
||||
[buildLastHourSeries, buildSparkline]
|
||||
);
|
||||
|
||||
return {
|
||||
requestsSparkline,
|
||||
tokensSparkline,
|
||||
rpmSparkline,
|
||||
tpmSparkline,
|
||||
costSparkline
|
||||
};
|
||||
}
|
||||
153
src/components/usage/hooks/useUsageData.ts
Normal file
153
src/components/usage/hooks/useUsageData.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { usageApi } from '@/services/api/usage';
|
||||
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
|
||||
|
||||
export interface UsagePayload {
|
||||
total_requests?: number;
|
||||
success_count?: number;
|
||||
failure_count?: number;
|
||||
total_tokens?: number;
|
||||
apis?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UseUsageDataReturn {
|
||||
usage: UsagePayload | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
modelPrices: Record<string, ModelPrice>;
|
||||
setModelPrices: (prices: Record<string, ModelPrice>) => void;
|
||||
loadUsage: () => Promise<void>;
|
||||
handleExport: () => Promise<void>;
|
||||
handleImport: () => void;
|
||||
handleImportChange: (event: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||
importInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
exporting: boolean;
|
||||
importing: boolean;
|
||||
}
|
||||
|
||||
export function useUsageData(): UseUsageDataReturn {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
|
||||
const [usage, setUsage] = useState<UsagePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await usageApi.getUsage();
|
||||
const payload = data?.usage ?? data;
|
||||
setUsage(payload);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsage();
|
||||
setModelPrices(loadModelPrices());
|
||||
}, [loadUsage]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const data = await usageApi.exportUsage();
|
||||
const exportedAt =
|
||||
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
|
||||
const safeTimestamp = Number.isNaN(exportedAt.getTime())
|
||||
? new Date().toISOString()
|
||||
: exportedAt.toISOString();
|
||||
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
|
||||
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
showNotification(t('usage_stats.export_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
importInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
showNotification(t('usage_stats.import_invalid'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await usageApi.importUsage(payload);
|
||||
showNotification(
|
||||
t('usage_stats.import_success', {
|
||||
added: result?.added ?? 0,
|
||||
skipped: result?.skipped ?? 0,
|
||||
total: result?.total_requests ?? 0,
|
||||
failed: result?.failed_requests ?? 0
|
||||
}),
|
||||
'success'
|
||||
);
|
||||
await loadUsage();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
showNotification(
|
||||
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetModelPrices = useCallback((prices: Record<string, ModelPrice>) => {
|
||||
setModelPrices(prices);
|
||||
saveModelPrices(prices);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
usage,
|
||||
loading,
|
||||
error,
|
||||
modelPrices,
|
||||
setModelPrices: handleSetModelPrices,
|
||||
loadUsage,
|
||||
handleExport,
|
||||
handleImport,
|
||||
handleImportChange,
|
||||
importInputRef,
|
||||
exporting,
|
||||
importing
|
||||
};
|
||||
}
|
||||
28
src/components/usage/index.ts
Normal file
28
src/components/usage/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Hooks
|
||||
export { useUsageData } from './hooks/useUsageData';
|
||||
export type { UsagePayload, UseUsageDataReturn } from './hooks/useUsageData';
|
||||
|
||||
export { useSparklines } from './hooks/useSparklines';
|
||||
export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './hooks/useSparklines';
|
||||
|
||||
export { useChartData } from './hooks/useChartData';
|
||||
export type { UseChartDataOptions, UseChartDataReturn } from './hooks/useChartData';
|
||||
|
||||
// Components
|
||||
export { StatCards } from './StatCards';
|
||||
export type { StatCardsProps } from './StatCards';
|
||||
|
||||
export { UsageChart } from './UsageChart';
|
||||
export type { UsageChartProps } from './UsageChart';
|
||||
|
||||
export { ChartLineSelector } from './ChartLineSelector';
|
||||
export type { ChartLineSelectorProps } from './ChartLineSelector';
|
||||
|
||||
export { ApiDetailsCard } from './ApiDetailsCard';
|
||||
export type { ApiDetailsCardProps } from './ApiDetailsCard';
|
||||
|
||||
export { ModelStatsCard } from './ModelStatsCard';
|
||||
export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
|
||||
|
||||
export { PriceSettingsCard } from './PriceSettingsCard';
|
||||
export type { PriceSettingsCardProps } from './PriceSettingsCard';
|
||||
@@ -8,3 +8,4 @@ export { useLocalStorage } from './useLocalStorage';
|
||||
export { useInterval } from './useInterval';
|
||||
export { useMediaQuery } from './useMediaQuery';
|
||||
export { usePagination } from './usePagination';
|
||||
export { useHeaderRefresh } from './useHeaderRefresh';
|
||||
|
||||
24
src/hooks/useHeaderRefresh.ts
Normal file
24
src/hooks/useHeaderRefresh.ts
Normal 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]);
|
||||
};
|
||||
@@ -6,14 +6,14 @@ import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import en from './locales/en.json';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-CN': { translation: zhCN },
|
||||
en: { translation: en }
|
||||
},
|
||||
lng: localStorage.getItem(STORAGE_KEY_LANGUAGE) || 'zh-CN',
|
||||
lng: getInitialLanguage(),
|
||||
fallbackLng: 'zh-CN',
|
||||
interpolation: {
|
||||
escapeValue: false // React 已经转义
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"alias": "Alias",
|
||||
"failure": "Failure",
|
||||
"unknown_error": "Unknown error",
|
||||
"quota_update_required": "Please update the CPA version or check for updates",
|
||||
"quota_check_credential": "Please check the credential status",
|
||||
"copy": "Copy",
|
||||
"custom_headers_label": "Custom Headers",
|
||||
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
|
||||
@@ -61,6 +63,7 @@
|
||||
"custom_connection_placeholder": "Eg: https://example.com:8317",
|
||||
"custom_connection_hint": "By default the current URL is used. Override it here if needed.",
|
||||
"use_current_address": "Use Current URL",
|
||||
"remember_password_label": "Remember password",
|
||||
"management_key_label": "Management Key:",
|
||||
"management_key_placeholder": "Enter the management key",
|
||||
"connect_button": "Connect",
|
||||
@@ -88,6 +91,7 @@
|
||||
"ai_providers": "AI Providers",
|
||||
"auth_files": "Auth Files",
|
||||
"oauth": "OAuth Login",
|
||||
"quota_management": "Quota Management",
|
||||
"usage_stats": "Usage Statistics",
|
||||
"config_management": "Config Management",
|
||||
"logs": "Logs Viewer",
|
||||
@@ -133,11 +137,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",
|
||||
@@ -217,6 +232,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",
|
||||
@@ -308,6 +344,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",
|
||||
@@ -323,6 +360,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",
|
||||
@@ -357,6 +397,53 @@
|
||||
"models_excluded_badge": "Excluded",
|
||||
"models_excluded_hint": "This model is excluded by OAuth"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity Quota",
|
||||
"empty_title": "No Antigravity Auth Files",
|
||||
"empty_desc": "Upload an Antigravity credential to view remaining quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
"empty_models": "No quota data available",
|
||||
"refresh_button": "Refresh Quota",
|
||||
"fetch_all": "Fetch All"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex Quota",
|
||||
"empty_title": "No Codex Auth Files",
|
||||
"empty_desc": "Upload a Codex credential to view quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
"missing_account_id": "Codex credential missing ChatGPT account ID",
|
||||
"empty_windows": "No quota data available",
|
||||
"no_access": "This credential has no Codex access (plan: free).",
|
||||
"refresh_button": "Refresh Quota",
|
||||
"fetch_all": "Fetch All",
|
||||
"primary_window": "5-hour limit",
|
||||
"secondary_window": "Weekly limit",
|
||||
"code_review_window": "Code review limit",
|
||||
"plan_label": "Plan",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI Quota",
|
||||
"empty_title": "No Gemini CLI Auth Files",
|
||||
"empty_desc": "Upload a Gemini CLI credential to view remaining quota.",
|
||||
"idle": "Not loaded. Click Refresh Button.",
|
||||
"loading": "Loading quota...",
|
||||
"load_failed": "Failed to load quota: {{message}}",
|
||||
"missing_auth_index": "Auth file missing auth_index",
|
||||
"missing_project_id": "Gemini CLI credential missing project ID",
|
||||
"empty_buckets": "No quota data available",
|
||||
"refresh_button": "Refresh Quota",
|
||||
"fetch_all": "Fetch All",
|
||||
"remaining_amount": "Remaining {{count}}"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex JSON Login",
|
||||
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
|
||||
@@ -413,6 +500,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",
|
||||
@@ -450,9 +565,9 @@
|
||||
"gemini_cli_oauth_title": "Gemini CLI OAuth",
|
||||
"gemini_cli_oauth_button": "Start Gemini CLI Login",
|
||||
"gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.",
|
||||
"gemini_cli_project_id_label": "Google Cloud Project ID:",
|
||||
"gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID",
|
||||
"gemini_cli_project_id_hint": "Project ID is required for Gemini CLI OAuth.",
|
||||
"gemini_cli_project_id_label": "Google Cloud Project ID (Optional):",
|
||||
"gemini_cli_project_id_placeholder": "Leave blank to auto-select first available project",
|
||||
"gemini_cli_project_id_hint": "Optional. If not provided, the system will automatically select the first available project from your account.",
|
||||
"gemini_cli_project_id_required": "Please enter a Google Cloud project ID.",
|
||||
"gemini_cli_oauth_url_label": "Authorization URL:",
|
||||
"gemini_cli_open_link": "Open Link",
|
||||
@@ -497,7 +612,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",
|
||||
@@ -604,8 +719,6 @@
|
||||
"request_log_download_title": "Download Request Log",
|
||||
"request_log_download_confirm": "Download request log for ID {{id}}?",
|
||||
"request_log_download_success": "Request log downloaded successfully",
|
||||
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
|
||||
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
|
||||
"empty_title": "No Logs Available",
|
||||
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
|
||||
"log_content": "Log Content",
|
||||
@@ -657,6 +770,12 @@
|
||||
"search_prev": "Previous",
|
||||
"search_next": "Next"
|
||||
},
|
||||
"quota_management": {
|
||||
"title": "Quota Management",
|
||||
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
|
||||
"refresh_files": "Refresh auth files",
|
||||
"refresh_files_and_quota": "Refresh files & quota"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Management Center Info",
|
||||
"connection_status_title": "Connection Status",
|
||||
@@ -692,7 +811,11 @@
|
||||
"link_webui_repo": "WebUI Repository",
|
||||
"link_webui_repo_desc": "Management Center frontend source code",
|
||||
"link_docs": "Documentation",
|
||||
"link_docs_desc": "Usage tutorials and configuration guides"
|
||||
"link_docs_desc": "Usage tutorials and configuration guides",
|
||||
"clear_login_title": "Local Login Data",
|
||||
"clear_login_desc": "Clear locally saved login data and sign out. Usage stats pricing settings will remain untouched.",
|
||||
"clear_login_button": "Clear login data",
|
||||
"clear_login_confirm": "Clear local login data and sign out now?"
|
||||
},
|
||||
"notification": {
|
||||
"debug_updated": "Debug settings updated",
|
||||
@@ -703,11 +826,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",
|
||||
@@ -721,6 +849,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",
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"alias": "别名",
|
||||
"failure": "失败",
|
||||
"unknown_error": "未知错误",
|
||||
"quota_update_required": "请更新 CPA 版本或检查更新",
|
||||
"quota_check_credential": "请检查凭证状态",
|
||||
"copy": "复制",
|
||||
"custom_headers_label": "自定义请求头",
|
||||
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
|
||||
@@ -61,6 +63,7 @@
|
||||
"custom_connection_placeholder": "例如: https://example.com:8317",
|
||||
"custom_connection_hint": "默认使用当前访问地址,若需要可手动输入其他地址。",
|
||||
"use_current_address": "使用当前地址",
|
||||
"remember_password_label": "记住密码",
|
||||
"management_key_label": "管理密钥:",
|
||||
"management_key_placeholder": "请输入管理密钥",
|
||||
"connect_button": "连接",
|
||||
@@ -88,6 +91,7 @@
|
||||
"ai_providers": "AI 提供商",
|
||||
"auth_files": "认证文件",
|
||||
"oauth": "OAuth 登录",
|
||||
"quota_management": "配额管理",
|
||||
"usage_stats": "使用统计",
|
||||
"config_management": "配置管理",
|
||||
"logs": "日志查看",
|
||||
@@ -133,11 +137,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 密钥管理",
|
||||
@@ -217,6 +232,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",
|
||||
@@ -308,6 +344,7 @@
|
||||
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
|
||||
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
|
||||
"upload_error_json": "只能上传JSON文件",
|
||||
"upload_error_size": "文件大小不能超过 {{maxSize}}",
|
||||
"upload_success": "文件上传成功",
|
||||
"download_success": "文件下载成功",
|
||||
"delete_success": "文件删除成功",
|
||||
@@ -323,6 +360,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",
|
||||
@@ -357,6 +397,53 @@
|
||||
"models_excluded_badge": "已排除",
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity 额度",
|
||||
"empty_title": "暂无 Antigravity 认证",
|
||||
"empty_desc": "上传 Antigravity 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
"empty_models": "暂无额度数据",
|
||||
"refresh_button": "刷新额度",
|
||||
"fetch_all": "获取全部"
|
||||
},
|
||||
"codex_quota": {
|
||||
"title": "Codex 额度",
|
||||
"empty_title": "暂无 Codex 认证",
|
||||
"empty_desc": "上传 Codex 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
"missing_account_id": "Codex 凭证缺少 ChatGPT 账号 ID",
|
||||
"empty_windows": "暂无额度数据",
|
||||
"no_access": "该凭证已无 Codex 访问权限(free)。",
|
||||
"refresh_button": "刷新额度",
|
||||
"fetch_all": "获取全部",
|
||||
"primary_window": "5 小时限额",
|
||||
"secondary_window": "周限额",
|
||||
"code_review_window": "代码审查限额",
|
||||
"plan_label": "套餐",
|
||||
"plan_plus": "Plus",
|
||||
"plan_team": "Team",
|
||||
"plan_free": "Free"
|
||||
},
|
||||
"gemini_cli_quota": {
|
||||
"title": "Gemini CLI 额度",
|
||||
"empty_title": "暂无 Gemini CLI 认证",
|
||||
"empty_desc": "上传 Gemini CLI 认证文件后即可查看额度。",
|
||||
"idle": "尚未加载额度,请点击刷新按钮。",
|
||||
"loading": "正在加载额度...",
|
||||
"load_failed": "额度获取失败:{{message}}",
|
||||
"missing_auth_index": "认证文件缺少 auth_index",
|
||||
"missing_project_id": "Gemini CLI 凭证缺少 Project ID",
|
||||
"empty_buckets": "暂无额度数据",
|
||||
"refresh_button": "刷新额度",
|
||||
"fetch_all": "获取全部",
|
||||
"remaining_amount": "剩余 {{count}}"
|
||||
},
|
||||
"vertex_import": {
|
||||
"title": "Vertex JSON 登录",
|
||||
"description": "上传 Google 服务账号 JSON,使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
|
||||
@@ -413,6 +500,34 @@
|
||||
"upgrade_required_title": "需要升级 CPA 版本",
|
||||
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI 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 模型映射功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。"
|
||||
},
|
||||
"auth_login": {
|
||||
"codex_oauth_title": "Codex OAuth",
|
||||
"codex_oauth_button": "开始 Codex 登录",
|
||||
@@ -450,9 +565,9 @@
|
||||
"gemini_cli_oauth_title": "Gemini CLI OAuth",
|
||||
"gemini_cli_oauth_button": "开始 Gemini CLI 登录",
|
||||
"gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。",
|
||||
"gemini_cli_project_id_label": "Google Cloud 项目 ID:",
|
||||
"gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID",
|
||||
"gemini_cli_project_id_hint": "请填写项目 ID,用于 Gemini CLI OAuth 登录。",
|
||||
"gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):",
|
||||
"gemini_cli_project_id_placeholder": "留空将自动选择第一个可用项目",
|
||||
"gemini_cli_project_id_hint": "可选填写项目 ID。如不填写,系统将自动选择您账号下的第一个可用项目。",
|
||||
"gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。",
|
||||
"gemini_cli_oauth_url_label": "授权链接:",
|
||||
"gemini_cli_open_link": "打开链接",
|
||||
@@ -497,7 +612,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 登录",
|
||||
@@ -604,8 +719,6 @@
|
||||
"request_log_download_title": "下载报文",
|
||||
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
|
||||
"request_log_download_success": "报文下载成功",
|
||||
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
|
||||
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
|
||||
"empty_title": "暂无日志记录",
|
||||
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
|
||||
"log_content": "日志内容",
|
||||
@@ -657,6 +770,12 @@
|
||||
"search_prev": "上一个",
|
||||
"search_next": "下一个"
|
||||
},
|
||||
"quota_management": {
|
||||
"title": "配额管理",
|
||||
"description": "集中查看 OAuth 额度与剩余情况",
|
||||
"refresh_files": "刷新认证文件",
|
||||
"refresh_files_and_quota": "刷新认证文件&额度"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "管理中心信息",
|
||||
"connection_status_title": "连接状态",
|
||||
@@ -692,7 +811,11 @@
|
||||
"link_webui_repo": "WebUI 仓库",
|
||||
"link_webui_repo_desc": "管理中心前端界面源代码",
|
||||
"link_docs": "使用教程",
|
||||
"link_docs_desc": "配置指南和使用说明"
|
||||
"link_docs_desc": "配置指南和使用说明",
|
||||
"clear_login_title": "本地登录信息",
|
||||
"clear_login_desc": "清理本地保存的登录信息并退出登录,不会影响使用统计中的价格设置。",
|
||||
"clear_login_button": "清理登录信息",
|
||||
"clear_login_confirm": "确认清理本地登录信息并退出登录?"
|
||||
},
|
||||
"notification": {
|
||||
"debug_updated": "调试设置已更新",
|
||||
@@ -703,11 +826,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密钥删除成功",
|
||||
@@ -721,6 +849,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": "必填字段不能为空",
|
||||
|
||||
@@ -397,6 +397,79 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// 状态监测栏
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 0;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.statusBlocks {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.statusBlock {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
min-width: 6px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scaleY(1.5);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.statusBlockSuccess {
|
||||
background-color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.statusBlockFailure {
|
||||
background-color: var(--danger-color, #ef4444);
|
||||
}
|
||||
|
||||
.statusBlockMixed {
|
||||
background-color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.statusBlockIdle {
|
||||
background-color: var(--border-secondary, #e5e7eb);
|
||||
}
|
||||
|
||||
.statusRate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.statusRateHigh {
|
||||
color: var(--success-badge-text, #065f46);
|
||||
background: var(--success-badge-bg, #d1fae5);
|
||||
}
|
||||
|
||||
.statusRateMedium {
|
||||
color: var(--warning-text, #92400e);
|
||||
background: var(--warning-bg, #fef3c7);
|
||||
}
|
||||
|
||||
.statusRateLow {
|
||||
color: var(--failure-badge-text, #991b1b);
|
||||
background: var(--failure-badge-bg, #fee2e2);
|
||||
}
|
||||
|
||||
// 暗色主题适配
|
||||
:global([data-theme='dark']) {
|
||||
.headerBadge {
|
||||
@@ -436,4 +509,23 @@
|
||||
.apiKeyEntryIndex {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.statusBlockIdle {
|
||||
background-color: var(--border-primary, #374151);
|
||||
}
|
||||
|
||||
.statusRateHigh {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.statusRateMedium {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.statusRateLow {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -124,7 +146,7 @@
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
cursor: text;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -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;
|
||||
@@ -162,6 +171,272 @@
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.codexGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.geminiCliGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityControls {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.antigravityControl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.codexControls {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.codexControl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.geminiCliControls {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.geminiCliControl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityCard {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
.geminiCliCard {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(231, 239, 255, 0.2),
|
||||
rgba(231, 239, 255, 0)
|
||||
);
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
padding-top: $spacing-sm;
|
||||
margin-top: $spacing-xs;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.quotaRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.quotaRowHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaModel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaBar {
|
||||
height: 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quotaBarFill {
|
||||
height: 100%;
|
||||
background-color: var(--success-color, #22c55e);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.quotaBarFillHigh {
|
||||
background-color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.quotaBarFillMedium {
|
||||
background-color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.quotaBarFillLow {
|
||||
background-color: var(--danger-color, #ef4444);
|
||||
}
|
||||
|
||||
.quotaMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
@include mobile {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaPercent {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quotaReset {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.quotaAmount {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quotaMessage {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
.quotaError {
|
||||
font-size: 12px;
|
||||
color: var(--danger-color);
|
||||
background-color: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
}
|
||||
|
||||
.quotaWarning {
|
||||
font-size: 12px;
|
||||
color: var(--warning-color, #f59e0b);
|
||||
background-color: rgba(245, 158, 11, 0.12);
|
||||
border: 1px solid var(--warning-color, #f59e0b);
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
}
|
||||
|
||||
.codexPlan {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.codexPlanLabel {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.codexPlanValue {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
// 单个认证文件卡片
|
||||
.fileCard {
|
||||
background-color: var(--bg-primary);
|
||||
@@ -250,6 +525,78 @@
|
||||
border-color: var(--failure-badge-border, #fca5a5);
|
||||
}
|
||||
|
||||
// 状态监测栏
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.statusBlocks {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.statusBlock {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
min-width: 6px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scaleY(1.5);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.statusBlockSuccess {
|
||||
background-color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.statusBlockFailure {
|
||||
background-color: var(--danger-color, #ef4444);
|
||||
}
|
||||
|
||||
.statusBlockMixed {
|
||||
background-color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.statusBlockIdle {
|
||||
background-color: var(--border-secondary, #e5e7eb);
|
||||
}
|
||||
|
||||
.statusRate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.statusRateHigh {
|
||||
color: var(--success-badge-text, #065f46);
|
||||
background: var(--success-badge-bg, #d1fae5);
|
||||
}
|
||||
|
||||
.statusRateMedium {
|
||||
color: var(--warning-text, #92400e);
|
||||
background: var(--warning-bg, #fef3c7);
|
||||
}
|
||||
|
||||
.statusRateLow {
|
||||
color: var(--failure-badge-text, #991b1b);
|
||||
background: var(--failure-badge-bg, #fee2e2);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
@@ -350,6 +697,86 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// OAuth 排除列表表单:提供商快捷标签
|
||||
.providerField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
|
||||
:global(.form-group) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.providerTagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.providerTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: $radius-full;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.providerTagActive {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
@@ -2,11 +2,10 @@
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -135,13 +134,17 @@
|
||||
.editorWrapper {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
height: 480px;
|
||||
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;
|
||||
@@ -222,7 +225,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -256,8 +259,4 @@
|
||||
.configCard {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.editorWrapper {
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IconEye, IconEyeOff } from '@/components/ui/icons';
|
||||
import { useAuthStore, useNotificationStore } from '@/stores';
|
||||
import { useAuthStore, useLanguageStore, useNotificationStore } from '@/stores';
|
||||
import { detectApiBaseFromLocation, normalizeApiBase } from '@/utils/connection';
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -12,21 +12,26 @@ export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const language = useLanguageStore((state) => state.language);
|
||||
const toggleLanguage = useLanguageStore((state) => state.toggleLanguage);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const restoreSession = useAuthStore((state) => state.restoreSession);
|
||||
const storedBase = useAuthStore((state) => state.apiBase);
|
||||
const storedKey = useAuthStore((state) => state.managementKey);
|
||||
const storedRememberPassword = useAuthStore((state) => state.rememberPassword);
|
||||
|
||||
const [apiBase, setApiBase] = useState('');
|
||||
const [managementKey, setManagementKey] = useState('');
|
||||
const [showCustomBase, setShowCustomBase] = useState(false);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [rememberPassword, setRememberPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoLoading, setAutoLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const detectedBase = useMemo(() => detectApiBaseFromLocation(), []);
|
||||
const nextLanguageLabel = language === 'zh-CN' ? t('language.english') : t('language.chinese');
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -35,6 +40,7 @@ export function LoginPage() {
|
||||
if (!autoLoggedIn) {
|
||||
setApiBase(storedBase || detectedBase);
|
||||
setManagementKey(storedKey || '');
|
||||
setRememberPassword(storedRememberPassword || Boolean(storedKey));
|
||||
}
|
||||
} finally {
|
||||
setAutoLoading(false);
|
||||
@@ -42,17 +48,13 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
init();
|
||||
}, [detectedBase, restoreSession, storedBase, storedKey]);
|
||||
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
|
||||
|
||||
if (isAuthenticated) {
|
||||
const redirect = (location.state as any)?.from?.pathname || '/';
|
||||
return <Navigate to={redirect} replace />;
|
||||
}
|
||||
|
||||
const handleUseCurrent = () => {
|
||||
setApiBase(detectedBase);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!managementKey.trim()) {
|
||||
setError(t('login.error_required'));
|
||||
@@ -63,7 +65,11 @@ export function LoginPage() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login({ apiBase: baseToUse, managementKey: managementKey.trim() });
|
||||
await login({
|
||||
apiBase: baseToUse,
|
||||
managementKey: managementKey.trim(),
|
||||
rememberPassword
|
||||
});
|
||||
showNotification(t('common.connected_status'), 'success');
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: any) {
|
||||
@@ -75,11 +81,31 @@ export function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && !loading) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [loading, handleSubmit]);
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="title">{t('title.login')}</div>
|
||||
<div className="login-title-row">
|
||||
<div className="title">{t('title.login')}</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="login-language-btn"
|
||||
onClick={toggleLanguage}
|
||||
title={t('language.switch')}
|
||||
aria-label={t('language.switch')}
|
||||
>
|
||||
{nextLanguageLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="subtitle">{t('login.subtitle')}</div>
|
||||
</div>
|
||||
|
||||
@@ -110,11 +136,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"
|
||||
@@ -136,15 +164,20 @@ export function LoginPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Button variant="secondary" onClick={handleUseCurrent}>
|
||||
{t('login.use_current_address')}
|
||||
</Button>
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||
</Button>
|
||||
<div className="toggle-advanced">
|
||||
<input
|
||||
id="remember-password-toggle"
|
||||
type="checkbox"
|
||||
checked={rememberPassword}
|
||||
onChange={(e) => setRememberPassword(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="remember-password-toggle">{t('login.remember_password_label')}</label>
|
||||
</div>
|
||||
|
||||
<Button fullWidth onClick={handleSubmit} loading={loading}>
|
||||
{loading ? t('login.submitting') : t('login.submit_button')}
|
||||
</Button>
|
||||
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
{autoLoading && (
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
@include mobile {
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -53,6 +57,11 @@
|
||||
gap: $spacing-lg;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@include mobile {
|
||||
gap: $spacing-md;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.logCard {
|
||||
@@ -61,6 +70,12 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@include mobile {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@@ -87,6 +102,11 @@
|
||||
:global(.form-group) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
@@ -161,13 +181,26 @@
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
flex: 0 0 auto;
|
||||
height: 480px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 280px;
|
||||
max-height: calc(100vh - 320px);
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
@include tablet {
|
||||
min-height: 240px;
|
||||
max-height: calc(100vh - 300px);
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
min-height: 360px;
|
||||
max-height: 480px;
|
||||
flex: 0 0 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.errorPanel {
|
||||
@@ -190,6 +223,17 @@
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: $spacing-xs;
|
||||
|
||||
> span {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loadMoreCount {
|
||||
@@ -201,6 +245,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
@include mobile {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
|
||||
> span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logList {
|
||||
@@ -227,9 +281,18 @@
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: $spacing-sm;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-xs;
|
||||
padding: 8px 10px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,12 +434,17 @@
|
||||
|
||||
@include mobile {
|
||||
max-width: 100%;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
|
||||
@include mobile {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
@@ -402,10 +470,65 @@
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
height: 360px;
|
||||
min-height: 200px;
|
||||
max-height: calc(100vh - 280px);
|
||||
}
|
||||
|
||||
.logRow {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.errorPanel {
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 600px) {
|
||||
.pageTitle {
|
||||
font-size: 20px;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.tabItem {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: $spacing-sm;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.logCard {
|
||||
padding: $spacing-sm;
|
||||
}
|
||||
|
||||
.logPanel {
|
||||
min-height: 160px;
|
||||
max-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.logRow {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.loadMoreBanner {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.errorPanel {
|
||||
height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -50,7 +51,8 @@ const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
|
||||
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
|
||||
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
|
||||
const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/;
|
||||
const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i;
|
||||
const LOG_LATENCY_REGEX =
|
||||
/\b(?:\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))(?:\s*\d+(?:\.\d+)?\s*(?:µs|us|ms|s|m))*\b/i;
|
||||
const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/;
|
||||
const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i;
|
||||
const LOG_REQUEST_ID_REGEX = /^([a-f0-9]{8}|--------)$/i;
|
||||
@@ -102,6 +104,12 @@ const normalizeTimestampToSeconds = (value: string): string => {
|
||||
return `${match[1]} ${match[2]}`;
|
||||
};
|
||||
|
||||
const extractLatency = (text: string): string | undefined => {
|
||||
const match = text.match(LOG_LATENCY_REGEX);
|
||||
if (!match) return undefined;
|
||||
return match[0].replace(/\s+/g, '');
|
||||
};
|
||||
|
||||
type ParsedLogLine = {
|
||||
raw: string;
|
||||
timestamp?: string;
|
||||
@@ -244,9 +252,9 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
// latency
|
||||
const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment));
|
||||
if (latencyIndex >= 0) {
|
||||
const match = segments[latencyIndex].match(LOG_LATENCY_REGEX);
|
||||
if (match) {
|
||||
latency = `${match[1]}${match[2]}`;
|
||||
const extracted = extractLatency(segments[latencyIndex]);
|
||||
if (extracted) {
|
||||
latency = extracted;
|
||||
consumed.add(latencyIndex);
|
||||
}
|
||||
}
|
||||
@@ -287,8 +295,8 @@ const parseLogLine = (raw: string): ParsedLogLine => {
|
||||
} else {
|
||||
statusCode = detectHttpStatusCode(remaining);
|
||||
|
||||
const latencyMatch = remaining.match(LOG_LATENCY_REGEX);
|
||||
if (latencyMatch) latency = `${latencyMatch[1]}${latencyMatch[2]}`;
|
||||
const extracted = extractLatency(remaining);
|
||||
if (extracted) latency = extracted;
|
||||
|
||||
ip = extractIp(remaining);
|
||||
|
||||
@@ -467,6 +475,8 @@ export function LogsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
useHeaderRefresh(() => loadLogs(false));
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
||||
try {
|
||||
@@ -851,10 +861,6 @@ export function LogsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hint">
|
||||
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="hint">{t('logs.loading')}</div>
|
||||
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -59,11 +59,13 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
|
||||
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
|
||||
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
|
||||
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
|
||||
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen },
|
||||
{ id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label', icon: iconIflow }
|
||||
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
|
||||
];
|
||||
|
||||
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
|
||||
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||
const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
|
||||
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
|
||||
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
|
||||
|
||||
const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => {
|
||||
return typeof icon === 'string' ? icon : icon[theme];
|
||||
@@ -105,12 +107,15 @@ export function OAuthPage() {
|
||||
const res = await oauthApi.getAuthStatus(state);
|
||||
if (res.status === 'ok') {
|
||||
updateProviderState(provider, { status: 'success', polling: false });
|
||||
showNotification(t('auth_login.codex_oauth_status_success'), 'success');
|
||||
showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success');
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
} else if (res.status === 'error') {
|
||||
updateProviderState(provider, { status: 'error', error: res.error, polling: false });
|
||||
showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error');
|
||||
showNotification(
|
||||
`${t(getAuthKey(provider, 'oauth_status_error'))} ${res.error || ''}`,
|
||||
'error'
|
||||
);
|
||||
window.clearInterval(timer);
|
||||
delete timers.current[provider];
|
||||
}
|
||||
@@ -125,12 +130,7 @@ export function OAuthPage() {
|
||||
|
||||
const startAuth = async (provider: OAuthProvider) => {
|
||||
const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined;
|
||||
if (provider === 'gemini-cli' && !projectId) {
|
||||
const message = t('auth_login.gemini_cli_project_id_required');
|
||||
updateProviderState(provider, { projectIdError: message });
|
||||
showNotification(message, 'warning');
|
||||
return;
|
||||
}
|
||||
// 项目 ID 现在是可选的,如果不输入将自动选择第一个可用项目
|
||||
if (provider === 'gemini-cli') {
|
||||
updateProviderState(provider, { projectIdError: undefined });
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export function OAuthPage() {
|
||||
try {
|
||||
const res = await oauthApi.startAuth(
|
||||
provider,
|
||||
provider === 'gemini-cli' ? { projectId: projectId! } : undefined
|
||||
provider === 'gemini-cli' ? { projectId: projectId || undefined } : undefined
|
||||
);
|
||||
updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true });
|
||||
if (res.state) {
|
||||
@@ -153,7 +153,7 @@ export function OAuthPage() {
|
||||
}
|
||||
} catch (err: any) {
|
||||
updateProviderState(provider, { status: 'error', error: err?.message, polling: false });
|
||||
showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error');
|
||||
showNotification(`${t(getAuthKey(provider, 'oauth_start_error'))} ${err?.message || ''}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}`}>
|
||||
@@ -347,14 +349,14 @@ export function OAuthPage() {
|
||||
<div className={styles.authUrlValue}>{state.url}</div>
|
||||
<div className={styles.authUrlActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
||||
{t('auth_login.codex_copy_link')}
|
||||
{t(getAuthKey(provider.id, 'copy_link'))}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{t('auth_login.codex_open_link')}
|
||||
{t(getAuthKey(provider.id, 'open_link'))}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,10 +401,10 @@ export function OAuthPage() {
|
||||
{state.status && state.status !== 'idle' && (
|
||||
<div className="status-badge" style={{ marginTop: 8 }}>
|
||||
{state.status === 'success'
|
||||
? t('auth_login.codex_oauth_status_success')
|
||||
? t(getAuthKey(provider.id, 'oauth_status_success'))
|
||||
: state.status === 'error'
|
||||
? `${t('auth_login.codex_oauth_status_error')} ${state.error || ''}`
|
||||
: t('auth_login.codex_oauth_status_waiting')}
|
||||
? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}`
|
||||
: t(getAuthKey(provider.id, 'oauth_status_waiting'))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
389
src/pages/QuotaPage.module.scss
Normal file
389
src/pages/QuotaPage.module.scss
Normal file
@@ -0,0 +1,389 @@
|
||||
@use '../styles/variables' as *;
|
||||
@use '../styles/mixins' as *;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
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 {
|
||||
padding: $spacing-md;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: $radius-md;
|
||||
color: var(--danger-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pageSizeSelect {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
cursor: text;
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.antigravityGrid,
|
||||
.codexGrid,
|
||||
.geminiCliGrid {
|
||||
display: grid;
|
||||
gap: $spacing-md;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.antigravityControls,
|
||||
.codexControls,
|
||||
.geminiCliControls {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.antigravityControl,
|
||||
.codexControl,
|
||||
.geminiCliControl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
.codexCard {
|
||||
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));
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
padding-top: $spacing-sm;
|
||||
margin-top: $spacing-xs;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.quotaRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.quotaRowHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaModel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@include mobile {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaBar {
|
||||
height: 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quotaBarFill {
|
||||
height: 100%;
|
||||
background-color: var(--success-color, #22c55e);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.quotaBarFillHigh {
|
||||
background-color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.quotaBarFillMedium {
|
||||
background-color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.quotaBarFillLow {
|
||||
background-color: var(--danger-color, #ef4444);
|
||||
}
|
||||
|
||||
.quotaMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
@include mobile {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.quotaPercent {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quotaReset {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.quotaAmount {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quotaMessage {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
.quotaError {
|
||||
font-size: 12px;
|
||||
color: var(--danger-color);
|
||||
background-color: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid var(--danger-color);
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
}
|
||||
|
||||
.quotaWarning {
|
||||
font-size: 12px;
|
||||
color: var(--warning-color, #f59e0b);
|
||||
background-color: rgba(245, 158, 11, 0.12);
|
||||
border: 1px solid var(--warning-color, #f59e0b);
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
}
|
||||
|
||||
.codexPlan {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.codexPlanLabel {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.codexPlanValue {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.fileCard {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: $shadow-md;
|
||||
border-color: rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.typeBadge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
margin-top: $spacing-lg;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: $spacing-xs $spacing-md;
|
||||
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;
|
||||
}
|
||||
}
|
||||
92
src/pages/QuotaPage.tsx
Normal file
92
src/pages/QuotaPage.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Quota management page - coordinates the three quota sections.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { authFilesApi, configFileApi } from '@/services/api';
|
||||
import {
|
||||
QuotaSection,
|
||||
ANTIGRAVITY_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
GEMINI_CLI_CONFIG
|
||||
} from '@/components/quota';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
import styles from './QuotaPage.module.scss';
|
||||
|
||||
export function QuotaPage() {
|
||||
const { t } = useTranslation();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
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('');
|
||||
try {
|
||||
const data = await authFilesApi.list();
|
||||
setFiles(data?.files || []);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleHeaderRefresh = useCallback(async () => {
|
||||
await Promise.all([loadConfig(), loadFiles()]);
|
||||
}, [loadConfig, loadFiles]);
|
||||
|
||||
useHeaderRefresh(handleHeaderRefresh);
|
||||
|
||||
useEffect(() => {
|
||||
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>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<QuotaSection
|
||||
config={ANTIGRAVITY_CONFIG}
|
||||
files={files}
|
||||
loading={loading}
|
||||
disabled={disableControls}
|
||||
/>
|
||||
<QuotaSection
|
||||
config={CODEX_CONFIG}
|
||||
files={files}
|
||||
loading={loading}
|
||||
disabled={disableControls}
|
||||
/>
|
||||
<QuotaSection
|
||||
config={GEMINI_CLI_CONFIG}
|
||||
files={files}
|
||||
loading={loading}
|
||||
disabled={disableControls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
margin: 0 0 $spacing-md 0;
|
||||
}
|
||||
|
||||
.clearLoginActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
gap: $spacing-sm;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IconGithub, IconBookOpen, IconExternalLink, IconCode } from '@/componen
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useModelsStore } from '@/stores';
|
||||
import { apiKeysApi } from '@/services/api/apiKeys';
|
||||
import { classifyModels } from '@/utils/models';
|
||||
import { STORAGE_KEY_AUTH } from '@/utils/constants';
|
||||
import styles from './SystemPage.module.scss';
|
||||
|
||||
export function SystemPage() {
|
||||
@@ -104,6 +105,15 @@ export function SystemPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearLoginStorage = () => {
|
||||
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
|
||||
auth.logout();
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
showNotification(t('notification.login_storage_cleared'), 'success');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig().catch(() => {
|
||||
// ignore
|
||||
@@ -248,6 +258,15 @@ export function SystemPage() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title={t('system_info.clear_login_title')}>
|
||||
<p className={styles.sectionDescription}>{t('system_info.clear_login_desc')}</p>
|
||||
<div className={styles.clearLoginActions}>
|
||||
<Button variant="danger" onClick={handleClearLoginStorage}>
|
||||
{t('system_info.clear_login_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
32
src/router/MainRoutes.tsx
Normal file
32
src/router/MainRoutes.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Navigate, useRoutes, type Location } from 'react-router-dom';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { SettingsPage } from '@/pages/SettingsPage';
|
||||
import { ApiKeysPage } from '@/pages/ApiKeysPage';
|
||||
import { AiProvidersPage } from '@/pages/AiProvidersPage';
|
||||
import { AuthFilesPage } from '@/pages/AuthFilesPage';
|
||||
import { OAuthPage } from '@/pages/OAuthPage';
|
||||
import { QuotaPage } from '@/pages/QuotaPage';
|
||||
import { UsagePage } from '@/pages/UsagePage';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { LogsPage } from '@/pages/LogsPage';
|
||||
import { SystemPage } from '@/pages/SystemPage';
|
||||
|
||||
const mainRoutes = [
|
||||
{ path: '/', element: <DashboardPage /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/api-keys', element: <ApiKeysPage /> },
|
||||
{ path: '/ai-providers', element: <AiProvidersPage /> },
|
||||
{ path: '/auth-files', element: <AuthFilesPage /> },
|
||||
{ path: '/oauth', element: <OAuthPage /> },
|
||||
{ path: '/quota', element: <QuotaPage /> },
|
||||
{ path: '/usage', element: <UsagePage /> },
|
||||
{ path: '/config', element: <ConfigPage /> },
|
||||
{ path: '/logs', element: <LogsPage /> },
|
||||
{ path: '/system', element: <SystemPage /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
];
|
||||
|
||||
export function MainRoutes({ location }: { location?: Location }) {
|
||||
return useRoutes(mainRoutes, location);
|
||||
}
|
||||
86
src/services/api/apiCall.ts
Normal file
86
src/services/api/apiCall.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Generic API call helper (proxied via management API).
|
||||
*/
|
||||
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface ApiCallRequest {
|
||||
authIndex?: string;
|
||||
method: string;
|
||||
url: string;
|
||||
header?: Record<string, string>;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface ApiCallResult<T = any> {
|
||||
statusCode: number;
|
||||
header: Record<string, string[]>;
|
||||
bodyText: string;
|
||||
body: T | null;
|
||||
}
|
||||
|
||||
const normalizeBody = (input: unknown): { bodyText: string; body: any | null } => {
|
||||
if (input === undefined || input === null) {
|
||||
return { bodyText: '', body: null };
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const text = input;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { bodyText: text, body: null };
|
||||
}
|
||||
try {
|
||||
return { bodyText: text, body: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
return { bodyText: text, body: text };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return { bodyText: JSON.stringify(input), body: input };
|
||||
} catch {
|
||||
return { bodyText: String(input), body: input };
|
||||
}
|
||||
};
|
||||
|
||||
export const getApiCallErrorMessage = (result: ApiCallResult): string => {
|
||||
const status = result.statusCode;
|
||||
const body = result.body;
|
||||
const bodyText = result.bodyText;
|
||||
let message = '';
|
||||
|
||||
if (body && typeof body === 'object') {
|
||||
message = body?.error?.message || body?.error || body?.message || '';
|
||||
} else if (typeof body === 'string') {
|
||||
message = body;
|
||||
}
|
||||
|
||||
if (!message && bodyText) {
|
||||
message = bodyText;
|
||||
}
|
||||
|
||||
if (status && message) return `${status} ${message}`.trim();
|
||||
if (status) return `HTTP ${status}`;
|
||||
return message || 'Request failed';
|
||||
};
|
||||
|
||||
export const apiCallApi = {
|
||||
request: async (
|
||||
payload: ApiCallRequest,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiCallResult> => {
|
||||
const response = await apiClient.post('/api-call', payload, config);
|
||||
const statusCode = Number(response?.status_code ?? response?.statusCode ?? 0);
|
||||
const header = (response?.header ?? response?.headers ?? {}) as Record<string, string[]>;
|
||||
const { bodyText, body } = normalizeBody(response?.body);
|
||||
|
||||
return {
|
||||
statusCode,
|
||||
header,
|
||||
bodyText,
|
||||
body
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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'),
|
||||
@@ -21,8 +59,7 @@ export const authFilesApi = {
|
||||
// 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 +68,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-mappings');
|
||||
const payload = (data && (data['oauth-model-mappings'] ?? 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-mappings', { channel, mappings }),
|
||||
|
||||
deleteOauthModelMappings: (channel: string) =>
|
||||
apiClient.delete(`/oauth-model-mappings?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)}`);
|
||||
|
||||
@@ -62,12 +62,37 @@ class ApiClient {
|
||||
return `${normalized}${MANAGEMENT_API_PREFIX}`;
|
||||
}
|
||||
|
||||
private readHeader(headers: Record<string, any>, keys: string[]): string | null {
|
||||
private readHeader(headers: Record<string, any> | undefined, keys: string[]): string | null {
|
||||
if (!headers) return null;
|
||||
|
||||
const normalizeValue = (value: unknown): string | null => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((entry) => entry !== undefined && entry !== null && String(entry).trim());
|
||||
return first !== undefined ? String(first) : null;
|
||||
}
|
||||
const text = String(value);
|
||||
return text ? text : null;
|
||||
};
|
||||
|
||||
const headerGetter = (headers as { get?: (name: string) => any }).get;
|
||||
if (typeof headerGetter === 'function') {
|
||||
for (const key of keys) {
|
||||
const match = normalizeValue(headerGetter.call(headers, key));
|
||||
if (match) return match;
|
||||
}
|
||||
}
|
||||
|
||||
const entries =
|
||||
typeof (headers as { entries?: () => Iterable<[string, any]> }).entries === 'function'
|
||||
? Array.from((headers as { entries: () => Iterable<[string, any]> }).entries())
|
||||
: Object.entries(headers);
|
||||
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value as string | undefined])
|
||||
entries.map(([key, value]) => [String(key).toLowerCase(), value])
|
||||
);
|
||||
for (const key of keys) {
|
||||
const match = normalized[key.toLowerCase()];
|
||||
const match = normalizeValue(normalized[key.toLowerCase()]);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './client';
|
||||
export * from './apiCall';
|
||||
export * from './config';
|
||||
export * from './configFile';
|
||||
export * from './apiKeys';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import axios from 'axios';
|
||||
import { normalizeModelList } from '@/utils/models';
|
||||
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string): string => {
|
||||
let normalized = String(baseUrl || '').trim();
|
||||
@@ -39,5 +40,35 @@ export const modelsApi = {
|
||||
});
|
||||
const payload = response.data?.data ?? response.data?.models ?? response.data;
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
},
|
||||
|
||||
async fetchModelsViaApiCall(
|
||||
baseUrl: string,
|
||||
apiKey?: string,
|
||||
headers: Record<string, string> = {}
|
||||
) {
|
||||
const endpoint = buildModelsEndpoint(baseUrl);
|
||||
if (!endpoint) {
|
||||
throw new Error('Invalid base url');
|
||||
}
|
||||
|
||||
const resolvedHeaders = { ...headers };
|
||||
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
|
||||
if (apiKey && !hasAuthHeader) {
|
||||
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const result = await apiCallApi.request({
|
||||
method: 'GET',
|
||||
url: endpoint,
|
||||
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
|
||||
});
|
||||
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
const payload = result.body ?? result.bodyText;
|
||||
return normalizeModelList(payload, { dedupe: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,8 +9,7 @@ export type OAuthProvider =
|
||||
| 'anthropic'
|
||||
| 'antigravity'
|
||||
| 'gemini-cli'
|
||||
| 'qwen'
|
||||
| 'iflow';
|
||||
| 'qwen';
|
||||
|
||||
export interface OAuthStartResponse {
|
||||
url: string;
|
||||
@@ -30,7 +29,7 @@ export interface IFlowCookieAuthResponse {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow'];
|
||||
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
|
||||
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
|
||||
'gemini-cli': 'gemini'
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,3 +8,4 @@ export { useLanguageStore } from './useLanguageStore';
|
||||
export { useAuthStore } from './useAuthStore';
|
||||
export { useConfigStore } from './useConfigStore';
|
||||
export { useModelsStore } from './useModelsStore';
|
||||
export { useQuotaStore } from './useQuotaStore';
|
||||
|
||||
@@ -34,6 +34,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
isAuthenticated: false,
|
||||
apiBase: '',
|
||||
managementKey: '',
|
||||
rememberPassword: false,
|
||||
serverVersion: null,
|
||||
serverBuildDate: null,
|
||||
connectionStatus: 'disconnected',
|
||||
@@ -52,16 +53,25 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
secureStorage.getItem<string>('apiUrl', { encrypt: true });
|
||||
const legacyKey = secureStorage.getItem<string>('managementKey');
|
||||
|
||||
const { apiBase, managementKey } = get();
|
||||
const { apiBase, managementKey, rememberPassword } = get();
|
||||
const resolvedBase = normalizeApiBase(apiBase || legacyBase || detectApiBaseFromLocation());
|
||||
const resolvedKey = managementKey || legacyKey || '';
|
||||
const resolvedRememberPassword = rememberPassword || Boolean(managementKey) || Boolean(legacyKey);
|
||||
|
||||
set({ apiBase: resolvedBase, managementKey: resolvedKey });
|
||||
set({
|
||||
apiBase: resolvedBase,
|
||||
managementKey: resolvedKey,
|
||||
rememberPassword: resolvedRememberPassword
|
||||
});
|
||||
apiClient.setConfig({ apiBase: resolvedBase, managementKey: resolvedKey });
|
||||
|
||||
if (wasLoggedIn && resolvedBase && resolvedKey) {
|
||||
try {
|
||||
await get().login({ apiBase: resolvedBase, managementKey: resolvedKey });
|
||||
await get().login({
|
||||
apiBase: resolvedBase,
|
||||
managementKey: resolvedKey,
|
||||
rememberPassword: resolvedRememberPassword
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Auto login failed:', error);
|
||||
@@ -79,6 +89,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
login: async (credentials) => {
|
||||
const apiBase = normalizeApiBase(credentials.apiBase);
|
||||
const managementKey = credentials.managementKey.trim();
|
||||
const rememberPassword = credentials.rememberPassword ?? get().rememberPassword ?? false;
|
||||
|
||||
try {
|
||||
set({ connectionStatus: 'connecting' });
|
||||
@@ -97,10 +108,15 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
isAuthenticated: true,
|
||||
apiBase,
|
||||
managementKey,
|
||||
rememberPassword,
|
||||
connectionStatus: 'connected',
|
||||
connectionError: null
|
||||
});
|
||||
localStorage.setItem('isLoggedIn', 'true');
|
||||
if (rememberPassword) {
|
||||
localStorage.setItem('isLoggedIn', 'true');
|
||||
} else {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
}
|
||||
} catch (error: any) {
|
||||
set({
|
||||
connectionStatus: 'error',
|
||||
@@ -185,7 +201,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
})),
|
||||
partialize: (state) => ({
|
||||
apiBase: state.apiBase,
|
||||
managementKey: state.managementKey,
|
||||
...(state.rememberPassword ? { managementKey: state.managementKey } : {}),
|
||||
rememberPassword: state.rememberPassword,
|
||||
serverVersion: state.serverVersion,
|
||||
serverBuildDate: state.serverBuildDate
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { persist } from 'zustand/middleware';
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
import i18n from '@/i18n';
|
||||
import { getInitialLanguage } from '@/utils/language';
|
||||
|
||||
interface LanguageState {
|
||||
language: Language;
|
||||
@@ -18,7 +19,7 @@ interface LanguageState {
|
||||
export const useLanguageStore = create<LanguageState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
language: 'zh-CN',
|
||||
language: getInitialLanguage(),
|
||||
|
||||
setLanguage: (language) => {
|
||||
// 切换 i18next 语言
|
||||
|
||||
49
src/stores/useQuotaStore.ts
Normal file
49
src/stores/useQuotaStore.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Quota cache that survives route switches.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
|
||||
|
||||
type QuotaUpdater<T> = T | ((prev: T) => T);
|
||||
|
||||
interface QuotaStoreState {
|
||||
antigravityQuota: Record<string, AntigravityQuotaState>;
|
||||
codexQuota: Record<string, CodexQuotaState>;
|
||||
geminiCliQuota: Record<string, GeminiCliQuotaState>;
|
||||
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
|
||||
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
|
||||
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
|
||||
clearQuotaCache: () => void;
|
||||
}
|
||||
|
||||
const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
|
||||
if (typeof updater === 'function') {
|
||||
return (updater as (value: T) => T)(prev);
|
||||
}
|
||||
return updater;
|
||||
};
|
||||
|
||||
export const useQuotaStore = create<QuotaStoreState>((set) => ({
|
||||
antigravityQuota: {},
|
||||
codexQuota: {},
|
||||
geminiCliQuota: {},
|
||||
setAntigravityQuota: (updater) =>
|
||||
set((state) => ({
|
||||
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
|
||||
})),
|
||||
setCodexQuota: (updater) =>
|
||||
set((state) => ({
|
||||
codexQuota: resolveUpdater(updater, state.codexQuota)
|
||||
})),
|
||||
setGeminiCliQuota: (updater) =>
|
||||
set((state) => ({
|
||||
geminiCliQuota: resolveUpdater(updater, state.geminiCliQuota)
|
||||
})),
|
||||
clearQuotaCache: () =>
|
||||
set({
|
||||
antigravityQuota: {},
|
||||
codexQuota: {},
|
||||
geminiCliQuota: {}
|
||||
})
|
||||
}));
|
||||
@@ -350,6 +350,32 @@ textarea {
|
||||
justify-content: center;
|
||||
z-index: $z-modal;
|
||||
padding: $spacing-lg;
|
||||
|
||||
&.modal-overlay-entering {
|
||||
animation: modal-overlay-fade-in 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
&.modal-overlay-closing {
|
||||
animation: modal-overlay-fade-out 0.35s ease-in forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-overlay-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-overlay-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
@@ -361,12 +387,77 @@ textarea {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
// 关闭按钮中心位置: right 12px + 16px = 28px, top 12px + 16px = 28px
|
||||
transform-origin: calc(100% - 28px) 28px;
|
||||
|
||||
&.modal-entering {
|
||||
animation: modal-scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
&.modal-closing {
|
||||
animation: modal-collapse-to-close 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.85) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-collapse-to-close {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close-floating {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: $radius-full;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
|
||||
z-index: 10;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
@@ -375,30 +466,6 @@ textarea {
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: $radius-md;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
|
||||
@@ -15,6 +15,15 @@ body {
|
||||
transition: background-color $transition-normal, color $transition-normal;
|
||||
}
|
||||
|
||||
html.modal-open,
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.modal-open .content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 滚动条样式
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.main-header {
|
||||
@@ -28,6 +35,9 @@
|
||||
width: 100%;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
@@ -230,6 +240,17 @@
|
||||
@supports (height: 100dvh) {
|
||||
height: calc(100dvh - var(--header-height));
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
height: auto;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
overflow: visible;
|
||||
padding-top: var(--header-height);
|
||||
|
||||
@supports (min-height: 100dvh) {
|
||||
min-height: calc(100dvh - var(--header-height));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@@ -327,7 +348,18 @@
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
height: 100%;
|
||||
|
||||
&.content-logs {
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
overflow: visible;
|
||||
overflow-y: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -338,6 +370,18 @@
|
||||
gap: $spacing-lg;
|
||||
overflow-x: hidden;
|
||||
|
||||
&.main-content-logs {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
@@ -354,6 +398,13 @@
|
||||
font-size: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.footer-version {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page {
|
||||
@@ -381,6 +432,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
text-align: center;
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
@@ -393,6 +445,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.login-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.login-language-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-color);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export interface LoginCredentials {
|
||||
apiBase: string;
|
||||
managementKey: string;
|
||||
rememberPassword?: boolean;
|
||||
}
|
||||
|
||||
// 认证状态
|
||||
@@ -14,6 +15,7 @@ export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
apiBase: string;
|
||||
managementKey: string;
|
||||
rememberPassword: boolean;
|
||||
serverVersion: string | null;
|
||||
serverBuildDate: string | null;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from './authFile';
|
||||
export * from './oauth';
|
||||
export * from './usage';
|
||||
export * from './log';
|
||||
export * from './quota';
|
||||
|
||||
@@ -9,8 +9,7 @@ export type OAuthProvider =
|
||||
| 'anthropic'
|
||||
| 'antigravity'
|
||||
| 'gemini-cli'
|
||||
| 'qwen'
|
||||
| 'iflow';
|
||||
| 'qwen';
|
||||
|
||||
// OAuth 流程状态
|
||||
export interface OAuthFlow {
|
||||
@@ -34,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[]>;
|
||||
|
||||
146
src/types/quota.ts
Normal file
146
src/types/quota.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Quota management types.
|
||||
*/
|
||||
|
||||
// Theme types
|
||||
export type ThemeColors = { bg: string; text: string; border?: string };
|
||||
export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
// API payload types
|
||||
export interface GeminiCliQuotaBucket {
|
||||
modelId?: string;
|
||||
model_id?: string;
|
||||
tokenType?: string;
|
||||
token_type?: string;
|
||||
remainingFraction?: number | string;
|
||||
remaining_fraction?: number | string;
|
||||
remainingAmount?: number | string;
|
||||
remaining_amount?: number | string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
}
|
||||
|
||||
export interface GeminiCliQuotaPayload {
|
||||
buckets?: GeminiCliQuotaBucket[];
|
||||
}
|
||||
|
||||
export interface AntigravityQuotaInfo {
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number | string;
|
||||
remaining_fraction?: number | string;
|
||||
remaining?: number | string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
quota_info?: {
|
||||
remainingFraction?: number | string;
|
||||
remaining_fraction?: number | string;
|
||||
remaining?: number | string;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AntigravityModelsPayload = Record<string, AntigravityQuotaInfo>;
|
||||
|
||||
export interface AntigravityQuotaGroupDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
identifiers: string[];
|
||||
labelFromModel?: boolean;
|
||||
}
|
||||
|
||||
export interface GeminiCliQuotaGroupDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
modelIds: string[];
|
||||
}
|
||||
|
||||
export interface GeminiCliParsedBucket {
|
||||
modelId: string;
|
||||
tokenType: string | null;
|
||||
remainingFraction: number | null;
|
||||
remainingAmount: number | null;
|
||||
resetTime: string | undefined;
|
||||
}
|
||||
|
||||
export interface CodexUsageWindow {
|
||||
used_percent?: number | string;
|
||||
usedPercent?: number | string;
|
||||
limit_window_seconds?: number | string;
|
||||
limitWindowSeconds?: number | string;
|
||||
reset_after_seconds?: number | string;
|
||||
resetAfterSeconds?: number | string;
|
||||
reset_at?: number | string;
|
||||
resetAt?: number | string;
|
||||
}
|
||||
|
||||
export interface CodexRateLimitInfo {
|
||||
allowed?: boolean;
|
||||
limit_reached?: boolean;
|
||||
limitReached?: boolean;
|
||||
primary_window?: CodexUsageWindow | null;
|
||||
primaryWindow?: CodexUsageWindow | null;
|
||||
secondary_window?: CodexUsageWindow | null;
|
||||
secondaryWindow?: CodexUsageWindow | null;
|
||||
}
|
||||
|
||||
export interface CodexUsagePayload {
|
||||
plan_type?: string;
|
||||
planType?: string;
|
||||
rate_limit?: CodexRateLimitInfo | null;
|
||||
rateLimit?: CodexRateLimitInfo | null;
|
||||
code_review_rate_limit?: CodexRateLimitInfo | null;
|
||||
codeReviewRateLimit?: CodexRateLimitInfo | null;
|
||||
}
|
||||
|
||||
// Quota state types
|
||||
export interface AntigravityQuotaGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
models: string[];
|
||||
remainingFraction: number;
|
||||
resetTime?: string;
|
||||
}
|
||||
|
||||
export interface AntigravityQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
groups: AntigravityQuotaGroup[];
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
export interface GeminiCliQuotaBucketState {
|
||||
id: string;
|
||||
label: string;
|
||||
remainingFraction: number | null;
|
||||
remainingAmount: number | null;
|
||||
resetTime: string | undefined;
|
||||
tokenType: string | null;
|
||||
modelIds?: string[];
|
||||
}
|
||||
|
||||
export interface GeminiCliQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
buckets: GeminiCliQuotaBucketState[];
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
|
||||
export interface CodexQuotaWindow {
|
||||
id: string;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
usedPercent: number | null;
|
||||
resetLabel: string;
|
||||
}
|
||||
|
||||
export interface CodexQuotaState {
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
windows: CodexQuotaWindow[];
|
||||
planType?: string | null;
|
||||
error?: string;
|
||||
errorStatus?: number;
|
||||
}
|
||||
@@ -42,16 +42,14 @@ export const OAUTH_CARD_IDS = [
|
||||
'anthropic-oauth-card',
|
||||
'antigravity-oauth-card',
|
||||
'gemini-cli-oauth-card',
|
||||
'qwen-oauth-card',
|
||||
'iflow-oauth-card'
|
||||
'qwen-oauth-card'
|
||||
];
|
||||
export const OAUTH_PROVIDERS = {
|
||||
CODEX: 'codex',
|
||||
ANTHROPIC: 'anthropic',
|
||||
ANTIGRAVITY: 'antigravity',
|
||||
GEMINI_CLI: 'gemini-cli',
|
||||
QWEN: 'qwen',
|
||||
IFLOW: 'iflow'
|
||||
QWEN: 'qwen'
|
||||
} as const;
|
||||
|
||||
// API 端点
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user