feat(router): add GSAP page transition animations

This commit is contained in:
LTbinglingfeng
2026-01-02 00:01:25 +08:00
parent 3a66dc225d
commit 946ed36af0
8 changed files with 234 additions and 30 deletions

7
package-lock.json generated
View File

@@ -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,

View File

@@ -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",

View File

@@ -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>
); );

View 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;
}
}

View 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>
);
}

View File

@@ -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
View 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);
}

View File

@@ -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 {