mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-02 19:00:49 +08:00
feat(router): add GSAP page transition animations
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 0 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/components/common/PageTransition.tsx
Normal file
137
src/components/common/PageTransition.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { ReactNode, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }: PageTransitionProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAnimating) return;
|
||||||
|
if (location.key === currentLayerKey) return;
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Run GSAP animation when animating starts
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isAnimating) return;
|
||||||
|
|
||||||
|
if (!currentLayerRef.current) return;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
tl.fromTo(
|
||||||
|
exitingLayerRef.current,
|
||||||
|
{ y: 0, opacity: 1 },
|
||||||
|
{
|
||||||
|
y: 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,
|
||||||
|
{ y: transitionDirection === 'forward' ? '100%' : '-100%', opacity: 0 },
|
||||||
|
{
|
||||||
|
y: 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]);
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -365,6 +367,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();
|
||||||
@@ -510,7 +524,10 @@ export function MainLayout() {
|
|||||||
|
|
||||||
<div className={`content${isLogsPage ? ' content-logs' : ''}`}>
|
<div className={`content${isLogsPage ? ' content-logs' : ''}`}>
|
||||||
<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}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user