mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Merge branch 'dev'
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"gsap": "^3.14.2",
|
||||||
"i18next": "^25.7.1",
|
"i18next": "^25.7.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
@@ -3194,6 +3195,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"gsap": "^3.14.2",
|
||||||
"i18next": "^25.7.1",
|
"i18next": "^25.7.1",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
|||||||
31
src/App.tsx
31
src/App.tsx
@@ -1,17 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
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 { 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 { QuotaPage } from '@/pages/QuotaPage';
|
|
||||||
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 { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||||
import { MainLayout } from '@/components/layout/MainLayout';
|
import { MainLayout } from '@/components/layout/MainLayout';
|
||||||
@@ -75,27 +64,13 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/*"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
</ProtectedRoute>
|
</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="quota" element={<QuotaPage />} />
|
|
||||||
<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>
|
</Routes>
|
||||||
</HashRouter>
|
</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,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { PageTransition } from '@/components/common/PageTransition';
|
||||||
|
import { MainRoutes } from '@/router/MainRoutes';
|
||||||
import {
|
import {
|
||||||
IconBot,
|
IconBot,
|
||||||
IconChartLine,
|
IconChartLine,
|
||||||
@@ -200,6 +202,7 @@ export function MainLayout() {
|
|||||||
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
const [requestLogDraft, setRequestLogDraft] = useState(false);
|
||||||
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
const [requestLogTouched, setRequestLogTouched] = useState(false);
|
||||||
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
const [requestLogSaving, setRequestLogSaving] = useState(false);
|
||||||
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const brandCollapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const headerRef = useRef<HTMLElement | null>(null);
|
const headerRef = useRef<HTMLElement | null>(null);
|
||||||
const versionTapCount = useRef(0);
|
const versionTapCount = useRef(0);
|
||||||
@@ -341,6 +344,7 @@ export function MainLayout() {
|
|||||||
});
|
});
|
||||||
}, [fetchConfig]);
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
|
||||||
const statusClass =
|
const statusClass =
|
||||||
connectionStatus === 'connected'
|
connectionStatus === 'connected'
|
||||||
? 'success'
|
? 'success'
|
||||||
@@ -365,6 +369,18 @@ export function MainLayout() {
|
|||||||
: []),
|
: []),
|
||||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
{ 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 () => {
|
const handleRefreshAll = async () => {
|
||||||
clearCache();
|
clearCache();
|
||||||
@@ -508,9 +524,13 @@ export function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className={`content${isLogsPage ? ' content-logs' : ''}`}>
|
<div className={`content${isLogsPage ? ' content-logs' : ''}`} ref={contentRef}>
|
||||||
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
|
||||||
<Outlet />
|
<PageTransition
|
||||||
|
render={(location) => <MainRoutes location={location} />}
|
||||||
|
getRouteOrder={getRouteOrder}
|
||||||
|
scrollContainerRef={contentRef}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
|
import { useState, useEffect, useCallback, useRef, type PropsWithChildren, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { IconX } from './icons';
|
import { IconX } from './icons';
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
@@ -10,6 +11,26 @@ interface ModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CLOSE_ANIMATION_DURATION = 350;
|
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>) {
|
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -60,12 +81,20 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const shouldLockScroll = open || isVisible;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldLockScroll) return;
|
||||||
|
lockScroll();
|
||||||
|
return () => unlockScroll();
|
||||||
|
}, [shouldLockScroll]);
|
||||||
|
|
||||||
if (!open && !isVisible) return null;
|
if (!open && !isVisible) return null;
|
||||||
|
|
||||||
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`;
|
||||||
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}`;
|
||||||
|
|
||||||
return (
|
const modalContent = (
|
||||||
<div className={overlayClass}>
|
<div className={overlayClass}>
|
||||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||||
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
||||||
@@ -79,4 +108,10 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return modalContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState
|
|||||||
export function AiProvidersPage() {
|
export function AiProvidersPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const { theme } = useThemeStore();
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||||
|
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
@@ -1221,7 +1221,6 @@ export function AiProvidersPage() {
|
|||||||
renderContent: (item: T, index: number) => ReactNode,
|
renderContent: (item: T, index: number) => ReactNode,
|
||||||
onEdit: (index: number) => void,
|
onEdit: (index: number) => void,
|
||||||
onDelete: (item: T) => void,
|
onDelete: (item: T) => void,
|
||||||
addLabel: string,
|
|
||||||
emptyTitle: string,
|
emptyTitle: string,
|
||||||
emptyDescription: string,
|
emptyDescription: string,
|
||||||
deleteLabel?: string,
|
deleteLabel?: string,
|
||||||
@@ -1239,11 +1238,6 @@ export function AiProvidersPage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
title={emptyTitle}
|
title={emptyTitle}
|
||||||
description={emptyDescription}
|
description={emptyDescription}
|
||||||
action={
|
|
||||||
<Button onClick={() => onEdit(-1)} disabled={disableControls}>
|
|
||||||
{addLabel}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1388,7 +1382,6 @@ export function AiProvidersPage() {
|
|||||||
},
|
},
|
||||||
(index) => openGeminiModal(index),
|
(index) => openGeminiModal(index),
|
||||||
(item) => deleteGemini(item.apiKey),
|
(item) => deleteGemini(item.apiKey),
|
||||||
t('ai_providers.gemini_add_button'),
|
|
||||||
t('ai_providers.gemini_empty_title'),
|
t('ai_providers.gemini_empty_title'),
|
||||||
t('ai_providers.gemini_empty_desc'),
|
t('ai_providers.gemini_empty_desc'),
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1409,7 +1402,11 @@ export function AiProvidersPage() {
|
|||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<span className={styles.cardTitle}>
|
<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')}
|
{t('ai_providers.codex_title')}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -1508,7 +1505,6 @@ export function AiProvidersPage() {
|
|||||||
},
|
},
|
||||||
(index) => openProviderModal('codex', index),
|
(index) => openProviderModal('codex', index),
|
||||||
(item) => deleteProviderEntry('codex', item.apiKey),
|
(item) => deleteProviderEntry('codex', item.apiKey),
|
||||||
t('ai_providers.codex_add_button'),
|
|
||||||
t('ai_providers.codex_empty_title'),
|
t('ai_providers.codex_empty_title'),
|
||||||
t('ai_providers.codex_empty_desc'),
|
t('ai_providers.codex_empty_desc'),
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1644,7 +1640,6 @@ export function AiProvidersPage() {
|
|||||||
},
|
},
|
||||||
(index) => openProviderModal('claude', index),
|
(index) => openProviderModal('claude', index),
|
||||||
(item) => deleteProviderEntry('claude', item.apiKey),
|
(item) => deleteProviderEntry('claude', item.apiKey),
|
||||||
t('ai_providers.claude_add_button'),
|
|
||||||
t('ai_providers.claude_empty_title'),
|
t('ai_providers.claude_empty_title'),
|
||||||
t('ai_providers.claude_empty_desc'),
|
t('ai_providers.claude_empty_desc'),
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1743,7 +1738,11 @@ export function AiProvidersPage() {
|
|||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<span className={styles.cardTitle}>
|
<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')}
|
{t('ai_providers.openai_title')}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -1867,7 +1866,6 @@ export function AiProvidersPage() {
|
|||||||
},
|
},
|
||||||
(index) => openOpenaiModal(index),
|
(index) => openOpenaiModal(index),
|
||||||
(item) => deleteOpenai(item.name),
|
(item) => deleteOpenai(item.name),
|
||||||
t('ai_providers.openai_add_button'),
|
|
||||||
t('ai_providers.openai_empty_title'),
|
t('ai_providers.openai_empty_title'),
|
||||||
t('ai_providers.openai_empty_desc')
|
t('ai_providers.openai_empty_desc')
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -59,11 +59,10 @@ 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: '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: '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: '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: '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 }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
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 getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_');
|
||||||
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
|
const getAuthKey = (provider: OAuthProvider, suffix: string) =>
|
||||||
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
|
`auth_login.${getProviderI18nPrefix(provider)}_${suffix}`;
|
||||||
|
|||||||
@@ -1031,7 +1031,7 @@ export function QuotaPage() {
|
|||||||
const windows: CodexQuotaWindow[] = [];
|
const windows: CodexQuotaWindow[] = [];
|
||||||
const addWindow = (
|
const addWindow = (
|
||||||
id: string,
|
id: string,
|
||||||
label: string,
|
labelKey: string,
|
||||||
window?: CodexUsageWindow | null,
|
window?: CodexUsageWindow | null,
|
||||||
limitReached?: boolean,
|
limitReached?: boolean,
|
||||||
allowed?: boolean
|
allowed?: boolean
|
||||||
@@ -1044,7 +1044,8 @@ export function QuotaPage() {
|
|||||||
usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
||||||
windows.push({
|
windows.push({
|
||||||
id,
|
id,
|
||||||
label,
|
label: t(labelKey),
|
||||||
|
labelKey,
|
||||||
usedPercent,
|
usedPercent,
|
||||||
resetLabel
|
resetLabel
|
||||||
});
|
});
|
||||||
@@ -1052,21 +1053,21 @@ export function QuotaPage() {
|
|||||||
|
|
||||||
addWindow(
|
addWindow(
|
||||||
'primary',
|
'primary',
|
||||||
t('codex_quota.primary_window'),
|
'codex_quota.primary_window',
|
||||||
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
||||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||||
rateLimit?.allowed
|
rateLimit?.allowed
|
||||||
);
|
);
|
||||||
addWindow(
|
addWindow(
|
||||||
'secondary',
|
'secondary',
|
||||||
t('codex_quota.secondary_window'),
|
'codex_quota.secondary_window',
|
||||||
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
||||||
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
||||||
rateLimit?.allowed
|
rateLimit?.allowed
|
||||||
);
|
);
|
||||||
addWindow(
|
addWindow(
|
||||||
'code-review',
|
'code-review',
|
||||||
t('codex_quota.code_review_window'),
|
'codex_quota.code_review_window',
|
||||||
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
||||||
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
||||||
codeReviewLimit?.allowed
|
codeReviewLimit?.allowed
|
||||||
@@ -1552,10 +1553,12 @@ export function QuotaPage() {
|
|||||||
? styles.quotaBarFillMedium
|
? styles.quotaBarFillMedium
|
||||||
: styles.quotaBarFillLow;
|
: styles.quotaBarFillLow;
|
||||||
|
|
||||||
|
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={window.id} className={styles.quotaRow}>
|
<div key={window.id} className={styles.quotaRow}>
|
||||||
<div className={styles.quotaRowHeader}>
|
<div className={styles.quotaRowHeader}>
|
||||||
<span className={styles.quotaModel}>{window.label}</span>
|
<span className={styles.quotaModel}>{windowLabel}</span>
|
||||||
<div className={styles.quotaMeta}>
|
<div className={styles.quotaMeta}>
|
||||||
<span className={styles.quotaPercent}>{percentLabel}</span>
|
<span className={styles.quotaPercent}>{percentLabel}</span>
|
||||||
<span className={styles.quotaReset}>{window.resetLabel}</span>
|
<span className={styles.quotaReset}>{window.resetLabel}</span>
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -9,8 +9,7 @@ export type OAuthProvider =
|
|||||||
| 'anthropic'
|
| 'anthropic'
|
||||||
| 'antigravity'
|
| 'antigravity'
|
||||||
| 'gemini-cli'
|
| 'gemini-cli'
|
||||||
| 'qwen'
|
| 'qwen';
|
||||||
| 'iflow';
|
|
||||||
|
|
||||||
export interface OAuthStartResponse {
|
export interface OAuthStartResponse {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -30,7 +29,7 @@ export interface IFlowCookieAuthResponse {
|
|||||||
type?: string;
|
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>> = {
|
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
|
||||||
'gemini-cli': 'gemini'
|
'gemini-cli': 'gemini'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ body {
|
|||||||
transition: background-color $transition-normal, color $transition-normal;
|
transition: background-color $transition-normal, color $transition-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.modal-open,
|
||||||
|
body.modal-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.modal-open .content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
// 滚动条样式
|
// 滚动条样式
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -348,6 +348,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
&.content-logs {
|
&.content-logs {
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ export type OAuthProvider =
|
|||||||
| 'anthropic'
|
| 'anthropic'
|
||||||
| 'antigravity'
|
| 'antigravity'
|
||||||
| 'gemini-cli'
|
| 'gemini-cli'
|
||||||
| 'qwen'
|
| 'qwen';
|
||||||
| 'iflow';
|
|
||||||
|
|
||||||
// OAuth 流程状态
|
// OAuth 流程状态
|
||||||
export interface OAuthFlow {
|
export interface OAuthFlow {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface GeminiCliQuotaState {
|
|||||||
export interface CodexQuotaWindow {
|
export interface CodexQuotaWindow {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
labelKey?: string;
|
||||||
usedPercent: number | null;
|
usedPercent: number | null;
|
||||||
resetLabel: string;
|
resetLabel: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,16 +42,14 @@ export const OAUTH_CARD_IDS = [
|
|||||||
'anthropic-oauth-card',
|
'anthropic-oauth-card',
|
||||||
'antigravity-oauth-card',
|
'antigravity-oauth-card',
|
||||||
'gemini-cli-oauth-card',
|
'gemini-cli-oauth-card',
|
||||||
'qwen-oauth-card',
|
'qwen-oauth-card'
|
||||||
'iflow-oauth-card'
|
|
||||||
];
|
];
|
||||||
export const OAUTH_PROVIDERS = {
|
export const OAUTH_PROVIDERS = {
|
||||||
CODEX: 'codex',
|
CODEX: 'codex',
|
||||||
ANTHROPIC: 'anthropic',
|
ANTHROPIC: 'anthropic',
|
||||||
ANTIGRAVITY: 'antigravity',
|
ANTIGRAVITY: 'antigravity',
|
||||||
GEMINI_CLI: 'gemini-cli',
|
GEMINI_CLI: 'gemini-cli',
|
||||||
QWEN: 'qwen',
|
QWEN: 'qwen'
|
||||||
IFLOW: 'iflow'
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// API 端点
|
// API 端点
|
||||||
|
|||||||
Reference in New Issue
Block a user