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
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,6 +10,8 @@ api.md
|
||||
usage.json
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
antigravity_usage.json
|
||||
codex_usage.json
|
||||
|
||||
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" />
|
||||
|
||||
7
package-lock.json
generated
7
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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
34
src/components/common/PageTransition.scss
Normal file
34
src/components/common/PageTransition.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
@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;
|
||||
will-change: transform, opacity;
|
||||
|
||||
// During animation, exit layer uses absolute positioning
|
||||
&--exit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// When both layers exist, current layer also needs positioning
|
||||
&--animating &__layer:not(&__layer--exit) {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
159
src/components/common/PageTransition.tsx
Normal file
159
src/components/common/PageTransition.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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.65;
|
||||
|
||||
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;
|
||||
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';
|
||||
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);
|
||||
}, [
|
||||
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 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: scrollOffset ? -scrollOffset : 0 });
|
||||
tl.fromTo(
|
||||
exitingLayerRef.current,
|
||||
{ yPercent: 0, opacity: 1 },
|
||||
{
|
||||
yPercent: transitionDirection === 'forward' ? -100 : 100,
|
||||
opacity: 0,
|
||||
duration: TRANSITION_DURATION,
|
||||
ease: 'power3.in', // slow start, fast end
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Enter animation: slide in from bottom (slow-to-fast)
|
||||
tl.fromTo(
|
||||
currentLayerRef.current,
|
||||
{ yPercent: transitionDirection === 'forward' ? 100 : -100, opacity: 0 },
|
||||
{
|
||||
yPercent: 0,
|
||||
opacity: 1,
|
||||
duration: TRANSITION_DURATION,
|
||||
ease: 'power2.in', // slow start, fast end
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
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 {
|
||||
@@ -41,6 +44,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 +177,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 +202,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 +213,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 +344,7 @@ export function MainLayout() {
|
||||
});
|
||||
}, [fetchConfig]);
|
||||
|
||||
|
||||
const statusClass =
|
||||
connectionStatus === 'connected'
|
||||
? 'success'
|
||||
@@ -353,6 +361,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,6 +369,18 @@ 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();
|
||||
@@ -503,16 +524,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>
|
||||
|
||||
@@ -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,108 @@ 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(() => {
|
||||
if (open) {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsVisible(true);
|
||||
setIsClosing(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleMaskClick}>
|
||||
<div className="modal" style={{ width }} role="dialog" aria-modal="true">
|
||||
if (isVisible) {
|
||||
startClose(false);
|
||||
}
|
||||
}, [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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -357,6 +361,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.",
|
||||
@@ -450,9 +501,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",
|
||||
@@ -604,8 +655,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 +706,11 @@
|
||||
"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"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "Management Center Info",
|
||||
"connection_status_title": "Connection Status",
|
||||
@@ -692,7 +746,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",
|
||||
@@ -705,6 +763,7 @@
|
||||
"logging_to_file_updated": "Logging settings updated",
|
||||
"request_log_updated": "Request logging setting updated",
|
||||
"ws_auth_updated": "WebSocket authentication setting 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",
|
||||
|
||||
@@ -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": "日志查看",
|
||||
@@ -357,6 +361,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。",
|
||||
@@ -450,9 +501,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": "打开链接",
|
||||
@@ -604,8 +655,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 +706,11 @@
|
||||
"search_prev": "上一个",
|
||||
"search_next": "下一个"
|
||||
},
|
||||
"quota_management": {
|
||||
"title": "配额管理",
|
||||
"description": "集中查看 OAuth 额度与剩余情况",
|
||||
"refresh_files": "刷新认证文件"
|
||||
},
|
||||
"system_info": {
|
||||
"title": "管理中心信息",
|
||||
"connection_status_title": "连接状态",
|
||||
@@ -692,7 +746,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": "调试设置已更新",
|
||||
@@ -705,6 +763,7 @@
|
||||
"logging_to_file_updated": "日志记录设置已更新",
|
||||
"request_log_updated": "请求日志设置已更新",
|
||||
"ws_auth_updated": "WebSocket 鉴权设置已更新",
|
||||
"login_storage_cleared": "本地登录信息已清理",
|
||||
"api_key_added": "API密钥添加成功",
|
||||
"api_key_updated": "API密钥更新成功",
|
||||
"api_key_deleted": "API密钥删除成功",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useInterval } from '@/hooks/useInterval';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -10,7 +11,14 @@ import { ModelInputList, modelsToEntries, entriesToModels } from '@/components/u
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { IconCheck, IconX } from '@/components/ui/icons';
|
||||
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
|
||||
import { ampcodeApi, modelsApi, providersApi, usageApi } from '@/services/api';
|
||||
import {
|
||||
ampcodeApi,
|
||||
apiCallApi,
|
||||
getApiCallErrorMessage,
|
||||
modelsApi,
|
||||
providersApi,
|
||||
usageApi
|
||||
} from '@/services/api';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
@@ -24,7 +32,8 @@ import type {
|
||||
AmpcodeConfig,
|
||||
AmpcodeModelMapping,
|
||||
} from '@/types';
|
||||
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
|
||||
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
|
||||
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
|
||||
import type { ModelInfo } from '@/utils/models';
|
||||
import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
@@ -89,18 +98,25 @@ const parseExcludedModels = (text: string): string[] =>
|
||||
const excludedModelsToText = (models?: string[]) =>
|
||||
Array.isArray(models) ? models.join('\n') : '';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = String(baseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/g, '');
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||
};
|
||||
|
||||
const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = String(baseUrl || '')
|
||||
.trim()
|
||||
.replace(/\/+$/g, '');
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.endsWith('/chat/completions')) {
|
||||
return trimmed;
|
||||
@@ -186,7 +202,7 @@ const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState
|
||||
export function AiProvidersPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { theme } = useThemeStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
@@ -202,6 +218,8 @@ export function AiProvidersPage() {
|
||||
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
|
||||
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
|
||||
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
|
||||
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
|
||||
const loadingKeyStatsRef = useRef(false);
|
||||
|
||||
const [modal, setModal] = useState<ProviderModal | null>(null);
|
||||
|
||||
@@ -273,13 +291,23 @@ export function AiProvidersPage() {
|
||||
[openaiForm.modelEntries]
|
||||
);
|
||||
|
||||
// 加载 key 统计
|
||||
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
|
||||
const loadKeyStats = useCallback(async () => {
|
||||
// 防止重复请求
|
||||
if (loadingKeyStatsRef.current) return;
|
||||
loadingKeyStatsRef.current = true;
|
||||
try {
|
||||
const stats = await usageApi.getKeyStats();
|
||||
const usageResponse = await usageApi.getUsage();
|
||||
const usageData = usageResponse?.usage ?? usageResponse;
|
||||
const stats = await usageApi.getKeyStats(usageData);
|
||||
setKeyStats(stats);
|
||||
// 收集 usage 明细用于状态栏
|
||||
const details = collectUsageDetails(usageData);
|
||||
setUsageDetails(details);
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loadingKeyStatsRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -311,6 +339,9 @@ export function AiProvidersPage() {
|
||||
loadKeyStats();
|
||||
}, [loadKeyStats]);
|
||||
|
||||
// 定时刷新状态数据(每240秒)
|
||||
useInterval(loadKeyStats, 240_000);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
|
||||
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
|
||||
@@ -466,7 +497,7 @@ export function AiProvidersPage() {
|
||||
.find((entry) => entry.apiKey?.trim())
|
||||
?.apiKey?.trim();
|
||||
const hasAuthHeader = Boolean(headers.Authorization || headers['authorization']);
|
||||
const list = await modelsApi.fetchModels(
|
||||
const list = await modelsApi.fetchModelsViaApiCall(
|
||||
baseUrl,
|
||||
hasAuthHeader ? undefined : firstKey,
|
||||
headers
|
||||
@@ -475,7 +506,7 @@ export function AiProvidersPage() {
|
||||
} catch (err: any) {
|
||||
if (allowFallback) {
|
||||
try {
|
||||
const list = await modelsApi.fetchModels(baseUrl);
|
||||
const list = await modelsApi.fetchModelsViaApiCall(baseUrl);
|
||||
setOpenaiDiscoveryModels(list);
|
||||
return;
|
||||
} catch (fallbackErr: any) {
|
||||
@@ -628,48 +659,40 @@ export function AiProvidersPage() {
|
||||
setOpenaiTestStatus('loading');
|
||||
setOpenaiTestMessage(t('ai_providers.openai_test_running'));
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), OPENAI_TEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stream: false,
|
||||
max_tokens: 5,
|
||||
}),
|
||||
});
|
||||
const rawText = await response.text();
|
||||
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 (!response.ok) {
|
||||
let errorMessage = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const parsed = rawText ? JSON.parse(rawText) : null;
|
||||
errorMessage = parsed?.error?.message || parsed?.message || errorMessage;
|
||||
} catch {
|
||||
if (rawText) {
|
||||
errorMessage = rawText;
|
||||
}
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
if (result.statusCode < 200 || result.statusCode >= 300) {
|
||||
throw new Error(getApiCallErrorMessage(result));
|
||||
}
|
||||
|
||||
setOpenaiTestStatus('success');
|
||||
setOpenaiTestMessage(t('ai_providers.openai_test_success'));
|
||||
} catch (err: any) {
|
||||
setOpenaiTestStatus('error');
|
||||
if (err?.name === 'AbortError') {
|
||||
const isTimeout =
|
||||
err?.code === 'ECONNABORTED' ||
|
||||
String(err?.message || '').toLowerCase().includes('timeout');
|
||||
if (isTimeout) {
|
||||
setOpenaiTestMessage(
|
||||
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
|
||||
);
|
||||
} else {
|
||||
setOpenaiTestMessage(`${t('ai_providers.openai_test_failed')}: ${err?.message || ''}`);
|
||||
}
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1090,13 +1113,116 @@ export function AiProvidersPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// 预计算所有 apiKey 的状态栏数据(避免每次渲染重复计算)
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
// 收集所有需要计算的 apiKey
|
||||
const allApiKeys = new Set<string>();
|
||||
geminiKeys.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
|
||||
codexConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
|
||||
claudeConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
|
||||
openaiProviders.forEach((p) => {
|
||||
(p.apiKeyEntries || []).forEach((e) => e.apiKey && allApiKeys.add(e.apiKey));
|
||||
});
|
||||
|
||||
// 预计算每个 apiKey 的状态数据
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [usageDetails, geminiKeys, codexConfigs, claudeConfigs, openaiProviders]);
|
||||
|
||||
// 预计算 OpenAI 提供商的汇总状态栏数据
|
||||
const openaiStatusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
openaiProviders.forEach((provider) => {
|
||||
const allKeys = (provider.apiKeyEntries || []).map((e) => e.apiKey).filter(Boolean);
|
||||
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [usageDetails, openaiProviders]);
|
||||
|
||||
// 渲染状态监测栏
|
||||
const renderStatusBar = (apiKey: string) => {
|
||||
const statusData = statusBarCache.get(apiKey) || calculateStatusBarData([], apiKey);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染 OpenAI 提供商的状态栏(汇总多个 apiKey)
|
||||
const renderOpenAIStatusBar = (providerName: string) => {
|
||||
const statusData = openaiStatusBarCache.get(providerName) || calculateStatusBarData([]);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const renderList = <T,>(
|
||||
items: T[],
|
||||
keyField: (item: T) => string,
|
||||
renderContent: (item: T, index: number) => ReactNode,
|
||||
onEdit: (index: number) => void,
|
||||
onDelete: (item: T) => void,
|
||||
addLabel: string,
|
||||
emptyTitle: string,
|
||||
emptyDescription: string,
|
||||
deleteLabel?: string,
|
||||
options?: {
|
||||
getRowDisabled?: (item: T, index: number) => boolean;
|
||||
@@ -1110,13 +1236,8 @@ export function AiProvidersPage() {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('common.info')}
|
||||
description={t('ai_providers.gemini_empty_desc')}
|
||||
action={
|
||||
<Button onClick={() => onEdit(-1)} disabled={disableControls}>
|
||||
{addLabel}
|
||||
</Button>
|
||||
}
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1254,12 +1375,15 @@ export function AiProvidersPage() {
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
{/* 状态监测栏 */}
|
||||
{renderStatusBar(item.apiKey)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
(index) => openGeminiModal(index),
|
||||
(item) => deleteGemini(item.apiKey),
|
||||
t('ai_providers.gemini_add_button'),
|
||||
t('ai_providers.gemini_empty_title'),
|
||||
t('ai_providers.gemini_empty_desc'),
|
||||
undefined,
|
||||
{
|
||||
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
|
||||
@@ -1278,7 +1402,11 @@ export function AiProvidersPage() {
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={theme === 'dark' ? iconOpenaiDark : iconOpenaiLight} alt="" className={styles.cardTitleIcon} />
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t('ai_providers.codex_title')}
|
||||
</span>
|
||||
}
|
||||
@@ -1370,12 +1498,15 @@ export function AiProvidersPage() {
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
{/* 状态监测栏 */}
|
||||
{renderStatusBar(item.apiKey)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
(index) => openProviderModal('codex', index),
|
||||
(item) => deleteProviderEntry('codex', item.apiKey),
|
||||
t('ai_providers.codex_add_button'),
|
||||
t('ai_providers.codex_empty_title'),
|
||||
t('ai_providers.codex_empty_desc'),
|
||||
undefined,
|
||||
{
|
||||
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
|
||||
@@ -1502,12 +1633,15 @@ export function AiProvidersPage() {
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
{/* 状态监测栏 */}
|
||||
{renderStatusBar(item.apiKey)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
(index) => openProviderModal('claude', index),
|
||||
(item) => deleteProviderEntry('claude', item.apiKey),
|
||||
t('ai_providers.claude_add_button'),
|
||||
t('ai_providers.claude_empty_title'),
|
||||
t('ai_providers.claude_empty_desc'),
|
||||
undefined,
|
||||
{
|
||||
getRowDisabled: (item) => hasDisableAllModelsRule(item.excludedModels),
|
||||
@@ -1604,7 +1738,11 @@ export function AiProvidersPage() {
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img src={theme === 'dark' ? iconOpenaiDark : iconOpenaiLight} alt="" className={styles.cardTitleIcon} />
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? iconOpenaiDark : iconOpenaiLight}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t('ai_providers.openai_title')}
|
||||
</span>
|
||||
}
|
||||
@@ -1721,12 +1859,15 @@ export function AiProvidersPage() {
|
||||
{t('stats.failure')}: {stats.failure}
|
||||
</span>
|
||||
</div>
|
||||
{/* 状态监测栏(汇总) */}
|
||||
{renderOpenAIStatusBar(item.name)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
(index) => openOpenaiModal(index),
|
||||
(item) => deleteOpenai(item.name),
|
||||
t('ai_providers.openai_add_button')
|
||||
t('ai_providers.openai_empty_title'),
|
||||
t('ai_providers.openai_empty_desc')
|
||||
)}
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -162,6 +162,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 +516,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 +688,60 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
.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 {
|
||||
@@ -134,8 +133,8 @@
|
||||
|
||||
.editorWrapper {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
height: 480px;
|
||||
flex: 1;
|
||||
min-height: 800px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
@@ -220,9 +219,9 @@
|
||||
.configCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
height: 1120px;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -254,10 +253,11 @@
|
||||
}
|
||||
|
||||
.configCard {
|
||||
height: 880px;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.editorWrapper {
|
||||
height: 360px;
|
||||
min-height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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) {
|
||||
@@ -79,7 +85,20 @@ export function LoginPage() {
|
||||
<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>
|
||||
|
||||
@@ -136,15 +155,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,10 +851,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 ? (
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -347,14 +347,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 +399,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>
|
||||
|
||||
333
src/pages/QuotaPage.module.scss
Normal file
333
src/pages/QuotaPage.module.scss
Normal file
@@ -0,0 +1,333 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
.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: pointer;
|
||||
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(3, minmax(0, 1fr));
|
||||
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(2, minmax(0, 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;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
1969
src/pages/QuotaPage.tsx
Normal file
1969
src/pages/QuotaPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
51
src/types/quota.ts
Normal file
51
src/types/quota.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Quota management 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 端点
|
||||
|
||||
42
src/utils/language.ts
Normal file
42
src/utils/language.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Language } from '@/types';
|
||||
import { STORAGE_KEY_LANGUAGE } from '@/utils/constants';
|
||||
|
||||
const parseStoredLanguage = (value: string): Language | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const candidate = parsed?.state?.language ?? parsed?.language ?? parsed;
|
||||
if (candidate === 'zh-CN' || candidate === 'en') {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
if (value === 'zh-CN' || value === 'en') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStoredLanguage = (): Language | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_LANGUAGE);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
return parseStoredLanguage(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getBrowserLanguage = (): Language => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
|
||||
return raw.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
|
||||
};
|
||||
|
||||
export const getInitialLanguage = (): Language => getStoredLanguage() ?? getBrowserLanguage();
|
||||
@@ -754,6 +754,103 @@ export function buildChartData(
|
||||
/**
|
||||
* 依据 usage 数据计算密钥使用统计
|
||||
*/
|
||||
/**
|
||||
* 状态栏单个格子的状态
|
||||
*/
|
||||
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
|
||||
|
||||
/**
|
||||
* 状态栏数据
|
||||
*/
|
||||
export interface StatusBarData {
|
||||
blocks: StatusBlockState[];
|
||||
successRate: number;
|
||||
totalSuccess: number;
|
||||
totalFailure: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算状态栏数据(最近1小时,分为20个5分钟的时间块)
|
||||
* 注意:20个块 × 5分钟 = 100分钟,但我们只使用最近60分钟的数据
|
||||
* 所以实际只有最后12个块可能有数据,前8个块将始终为 idle
|
||||
*/
|
||||
export function calculateStatusBarData(
|
||||
usageDetails: UsageDetail[],
|
||||
sourceFilter?: string,
|
||||
authIndexFilter?: number
|
||||
): StatusBarData {
|
||||
const BLOCK_COUNT = 20;
|
||||
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
const now = Date.now();
|
||||
const hourAgo = now - HOUR_MS;
|
||||
|
||||
// Initialize blocks
|
||||
const blockStats: Array<{ success: number; failure: number }> = Array.from(
|
||||
{ length: BLOCK_COUNT },
|
||||
() => ({ success: 0, failure: 0 })
|
||||
);
|
||||
|
||||
let totalSuccess = 0;
|
||||
let totalFailure = 0;
|
||||
|
||||
// Filter and bucket the usage details
|
||||
usageDetails.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply filters if provided
|
||||
if (sourceFilter !== undefined && detail.source !== sourceFilter) {
|
||||
return;
|
||||
}
|
||||
if (authIndexFilter !== undefined && detail.auth_index !== authIndexFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate which block this falls into (0 = oldest, 19 = newest)
|
||||
const ageMs = now - timestamp;
|
||||
const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS);
|
||||
|
||||
if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) {
|
||||
if (detail.failed) {
|
||||
blockStats[blockIndex].failure += 1;
|
||||
totalFailure += 1;
|
||||
} else {
|
||||
blockStats[blockIndex].success += 1;
|
||||
totalSuccess += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert stats to block states
|
||||
const blocks: StatusBlockState[] = blockStats.map((stat) => {
|
||||
if (stat.success === 0 && stat.failure === 0) {
|
||||
return 'idle';
|
||||
}
|
||||
if (stat.failure === 0) {
|
||||
return 'success';
|
||||
}
|
||||
if (stat.success === 0) {
|
||||
return 'failure';
|
||||
}
|
||||
return 'mixed';
|
||||
});
|
||||
|
||||
// Calculate success rate
|
||||
const total = totalSuccess + totalFailure;
|
||||
const successRate = total > 0 ? (totalSuccess / total) * 100 : 100;
|
||||
|
||||
return {
|
||||
blocks,
|
||||
successRate,
|
||||
totalSuccess,
|
||||
totalFailure
|
||||
};
|
||||
}
|
||||
|
||||
export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats {
|
||||
if (!usageData) {
|
||||
return { bySource: {}, byAuthIndex: {} };
|
||||
|
||||
Reference in New Issue
Block a user