feat: add route animation

feat: add route animation
This commit is contained in:
kangfenmao 2025-06-11 19:43:14 +08:00
parent 26d823e0a5
commit 0d9f1882b9
11 changed files with 182 additions and 45 deletions

View File

@ -86,6 +86,7 @@
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch", "epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-xml-parser": "^5.2.0", "fast-xml-parser": "^5.2.0",
"framer-motion": "^12.17.0",
"franc-min": "^6.2.0", "franc-min": "^6.2.0",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",

View File

@ -12,7 +12,6 @@ import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager' import StyleSheetManager from './context/StyleSheetManager'
import { ThemeProvider } from './context/ThemeProvider' import { ThemeProvider } from './context/ThemeProvider'
import NavigationHandler from './handler/NavigationHandler' import NavigationHandler from './handler/NavigationHandler'
import MainSidebar from './pages/home/MainSidebar/MainSidebar'
import Routes from './Routes' import Routes from './Routes'
function App(): React.ReactElement { function App(): React.ReactElement {
@ -27,7 +26,6 @@ function App(): React.ReactElement {
<TopViewContainer> <TopViewContainer>
<HashRouter> <HashRouter>
<NavigationHandler /> <NavigationHandler />
<MainSidebar />
<Routes /> <Routes />
</HashRouter> </HashRouter>
</TopViewContainer> </TopViewContainer>

View File

@ -1,4 +1,7 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Route, Routes, useLocation } from 'react-router-dom' import { Route, Routes, useLocation } from 'react-router-dom'
import styled from 'styled-components'
import AgentsPage from './pages/agents/AgentsPage' import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage' import AppsPage from './pages/apps/AppsPage'
@ -10,27 +13,93 @@ import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
import SettingsPage from './pages/settings/SettingsPage' import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage' import TranslatePage from './pages/translate/TranslatePage'
const WILDCARD_ROUTES = ['/settings', '/paintings', '/mcp-servers']
const RouteContainer = () => { const RouteContainer = () => {
const location = useLocation() const location = useLocation()
const isHomePage = location.pathname === '/' const isHomePage = location.pathname === '/'
const [isReady, setIsReady] = useState(false)
// 获取当前路径的主路由部分
const mainPath = WILDCARD_ROUTES.find((route) => location.pathname.startsWith(route))
// 使用主路由作为 key这样同一主路由下的切换不会触发动画
const animationKey = mainPath || location.pathname
// 路由变化时重置状态
useEffect(() => {
setIsReady(false)
// 给一个很短的延迟,确保组件已经渲染
const timer = setTimeout(() => setIsReady(true), 300)
return () => clearTimeout(timer)
}, [location.pathname])
return ( return (
<div style={{ display: 'flex', width: '100%', height: '100%', minWidth: '0' }}> <Container>
<HomePage style={{ display: isHomePage ? 'flex' : 'none' }} /> <HomePageWrapper />
<div style={{ display: isHomePage ? 'none' : 'flex', flex: 1 }}> <AnimatePresence mode="wait">
<Routes location={location}> {!isHomePage && (
<Route path="/agents" element={<AgentsPage />} /> <PageContainer
<Route path="/paintings/*" element={<PaintingsRoutePage />} /> key={animationKey}
<Route path="/translate" element={<TranslatePage />} /> initial={isReady ? 'initial' : false}
<Route path="/files" element={<FilesPage />} /> animate={isReady ? 'animate' : false}
<Route path="/knowledge" element={<KnowledgePage />} /> exit="exit"
<Route path="/apps" element={<AppsPage />} /> variants={pageVariants}
<Route path="/mcp-servers/*" element={<McpServersPage />} /> transition={pageTransition}>
<Route path="/settings/*" element={<SettingsPage />} /> <Routes location={location}>
</Routes> <Route path="/agents" element={<AgentsPage />} />
</div> <Route path="/paintings/*" element={<PaintingsRoutePage />} />
</div> <Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/mcp-servers/*" element={<McpServersPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</PageContainer>
)}
</AnimatePresence>
</Container>
) )
} }
const Container = styled.div`
position: relative;
width: 100%;
height: 100%;
min-width: 0;
overflow: hidden;
`
const HomePageWrapper = styled(HomePage)`
display: flex;
width: 100%;
height: 100%;
`
const PageContainer = styled(motion.div)`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-base);
display: flex;
width: 100%;
height: 100%;
z-index: 10;
`
const pageTransition = {
type: 'tween',
duration: 0.25,
ease: 'easeInOut'
}
const pageVariants = {
initial: { y: '100%' },
animate: { y: 0 },
exit: { y: '100%' }
}
export default RouteContainer export default RouteContainer

View File

@ -5,7 +5,7 @@ import { CircleArrowLeft, X } from 'lucide-react'
import type { FC, PropsWithChildren } from 'react' import type { FC, PropsWithChildren } from 'react'
import type { HTMLAttributes } from 'react' import type { HTMLAttributes } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import styled from 'styled-components' import styled, { keyframes } from 'styled-components'
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement> type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
@ -38,6 +38,19 @@ export const NavbarMain: FC<Props> = ({ children, ...props }) => {
) )
} }
const rotateAnimation = keyframes`
from {
transform: rotate(-180deg);
}
to {
transform: rotate(0);
}
`
const AnimatedButton = styled(Button)`
animation: ${rotateAnimation} 0.4s ease-out;
`
const MacCloseIcon = () => { const MacCloseIcon = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -45,7 +58,7 @@ const MacCloseIcon = () => {
return null return null
} }
return <Button type="text" icon={<X size={18} />} onClick={() => navigate('/')} className="nodrag" /> return <AnimatedButton type="text" icon={<X size={18} />} onClick={() => navigate('/')} className="nodrag" />
} }
const CloseIcon = () => { const CloseIcon = () => {
@ -90,6 +103,7 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
background-color: var(--color-background);
height: var(--navbar-height); height: var(--navbar-height);
max-height: var(--navbar-height); max-height: var(--navbar-height);
min-height: var(--navbar-height); min-height: var(--navbar-height);

View File

@ -7,6 +7,7 @@ import type { MenuProps } from 'antd'
import { Dropdown, message } from 'antd' import { Dropdown, message } from 'antd'
import { FC } from 'react' import { FC } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
@ -22,8 +23,15 @@ const App: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps() const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
const isPinned = pinned.some((p) => p.id === app.id) const isPinned = pinned.some((p) => p.id === app.id)
const isVisible = minapps.some((m) => m.id === app.id) const isVisible = minapps.some((m) => m.id === app.id)
const location = useLocation()
const navigate = useNavigate()
const isHome = location.pathname === '/'
const handleClick = () => { const handleClick = () => {
if (!isHome) {
navigate('/')
}
openMinappKeepAlive(app) openMinappKeepAlive(app)
onClick?.() onClick?.()
} }

View File

@ -1,7 +1,6 @@
import { Navbar } from '@renderer/components/app/Navbar' import { Navbar } from '@renderer/components/app/Navbar'
import NarrowModeIcon from '@renderer/components/Icons/NarrowModeIcon' import NarrowModeIcon from '@renderer/components/Icons/NarrowModeIcon'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import MinAppsPopover from '@renderer/components/Popups/MinAppsPopover'
import SearchPopup from '@renderer/components/Popups/SearchPopup' import SearchPopup from '@renderer/components/Popups/SearchPopup'
import { isLinux, isMac, isWindows } from '@renderer/config/constant' import { isLinux, isMac, isWindows } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
@ -17,6 +16,7 @@ import { Tooltip } from 'antd'
import { t } from 'i18next' import { t } from 'i18next'
import { LayoutGrid, PanelLeft, PanelRight, Search } from 'lucide-react' import { LayoutGrid, PanelLeft, PanelRight, Search } from 'lucide-react'
import { FC } from 'react' import { FC } from 'react'
import { useNavigate } from 'react-router'
import styled from 'styled-components' import styled from 'styled-components'
import SelectModelButton from './components/SelectModelButton' import SelectModelButton from './components/SelectModelButton'
@ -29,6 +29,7 @@ const ChatNavbar: FC = () => {
const isFullscreen = useFullscreen() const isFullscreen = useFullscreen()
const { sidebarIcons, narrowMode } = useSettings() const { sidebarIcons, narrowMode } = useSettings()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const navigate = useNavigate()
useShortcut('search_message', SearchPopup.show) useShortcut('search_message', SearchPopup.show)
@ -63,13 +64,11 @@ const ChatNavbar: FC = () => {
</NarrowIcon> </NarrowIcon>
</Tooltip> </Tooltip>
{sidebarIcons.visible.includes('minapp') && ( {sidebarIcons.visible.includes('minapp') && (
<MinAppsPopover> <Tooltip title={t('minapp.title')} mouseEnterDelay={0.8}>
<Tooltip title={t('minapp.title')} mouseEnterDelay={0.8}> <NarrowIcon onClick={() => navigate('/apps')}>
<NarrowIcon> <LayoutGrid size={18} />
<LayoutGrid size={18} /> </NarrowIcon>
</NarrowIcon> </Tooltip>
</Tooltip>
</MinAppsPopover>
)} )}
</HStack> </HStack>
</NavbarContainer> </NavbarContainer>

View File

@ -1,9 +1,11 @@
import { HStack } from '@renderer/components/Layout'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { FC, useEffect } from 'react' import { FC, useEffect } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import Chat from './Chat' import Chat from './Chat'
import ChatNavbar from './ChatNavbar' import ChatNavbar from './ChatNavbar'
import MainSidebar from './MainSidebar/MainSidebar'
const HomePage: FC<{ style?: React.CSSProperties }> = ({ style }) => { const HomePage: FC<{ style?: React.CSSProperties }> = ({ style }) => {
const { showAssistants, showTopics, topicPosition } = useSettings() const { showAssistants, showTopics, topicPosition } = useSettings()
@ -17,12 +19,15 @@ const HomePage: FC<{ style?: React.CSSProperties }> = ({ style }) => {
}, [showAssistants, showTopics, topicPosition]) }, [showAssistants, showTopics, topicPosition])
return ( return (
<Container id="home-page" style={style}> <HStack style={{ display: 'flex', flex: 1 }}>
<ChatNavbar /> <MainSidebar />
<ContentContainer id="content-container"> <Container id="home-page" style={style}>
<Chat /> <ChatNavbar />
</ContentContainer> <ContentContainer id="content-container">
</Container> <Chat />
</ContentContainer>
</Container>
</HStack>
) )
} }

View File

@ -133,10 +133,6 @@ const MainSidebar: FC = () => {
return null return null
} }
if (location.pathname !== '/') {
return null
}
return ( return (
<Container id="main-sidebar"> <Container id="main-sidebar">
<MainNavbar /> <MainNavbar />
@ -165,13 +161,7 @@ const MainSidebar: FC = () => {
transition={{ duration: 0.2 }}> transition={{ duration: 0.2 }}>
<SubMenu> <SubMenu>
{appMenuItems.map((item) => ( {appMenuItems.map((item) => (
<MainMenuItem <MainMenuItem key={item.path} active={isRoutes(item.path)} onClick={() => navigate(item.path)}>
key={item.path}
active={isRoutes(item.path)}
onClick={() => {
navigate(item.path)
setIsAppMenuExpanded(false)
}}>
<MainMenuItemLeft> <MainMenuItemLeft>
<MainMenuItemIcon>{item.icon}</MainMenuItemIcon> <MainMenuItemIcon>{item.icon}</MainMenuItemIcon>
<MainMenuItemText>{item.text}</MainMenuItemText> <MainMenuItemText>{item.text}</MainMenuItemText>

View File

@ -51,6 +51,7 @@ export const Container = styled.div`
width: var(--assistant-width); width: var(--assistant-width);
max-width: var(--assistant-width); max-width: var(--assistant-width);
border-right: 0.5px solid var(--color-border); border-right: 0.5px solid var(--color-border);
height: 100vh;
` `
export const MainMenu = styled.div` export const MainMenu = styled.div`

View File

@ -7,7 +7,7 @@ import ImageStorage from '@renderer/services/ImageStorage'
import { INITIAL_PROVIDERS } from '@renderer/store/llm' import { INITIAL_PROVIDERS } from '@renderer/store/llm'
import { Provider } from '@renderer/types' import { Provider } from '@renderer/types'
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils' import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd' import { Avatar, Button, Dropdown, Input, MenuProps, Spin, Tag } from 'antd'
import { Search, UserPen } from 'lucide-react' import { Search, UserPen } from 'lucide-react'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -27,6 +27,7 @@ const ProvidersList: FC = () => {
const [searchText, setSearchText] = useState<string>('') const [searchText, setSearchText] = useState<string>('')
const [dragging, setDragging] = useState(false) const [dragging, setDragging] = useState(false)
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({}) const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
const [ready, setReady] = useState(false)
useEffect(() => { useEffect(() => {
const loadAllLogos = async () => { const loadAllLogos = async () => {
@ -241,6 +242,18 @@ const ProvidersList: FC = () => {
return isProviderMatch || isModelMatch return isProviderMatch || isModelMatch
}) })
useEffect(() => {
setTimeout(() => setReady(true), 250)
}, [])
if (!ready) {
return (
<div style={{ width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Spin />
</div>
)
}
return ( return (
<Container className="selectable"> <Container className="selectable">
<ProviderListContainer> <ProviderListContainer>

View File

@ -5656,6 +5656,7 @@ __metadata:
eslint-plugin-unused-imports: "npm:^4.1.4" eslint-plugin-unused-imports: "npm:^4.1.4"
fast-diff: "npm:^1.3.0" fast-diff: "npm:^1.3.0"
fast-xml-parser: "npm:^5.2.0" fast-xml-parser: "npm:^5.2.0"
framer-motion: "npm:^12.17.0"
franc-min: "npm:^6.2.0" franc-min: "npm:^6.2.0"
fs-extra: "npm:^11.2.0" fs-extra: "npm:^11.2.0"
html-to-image: "npm:^1.11.13" html-to-image: "npm:^1.11.13"
@ -9863,6 +9864,28 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"framer-motion@npm:^12.17.0":
version: 12.17.0
resolution: "framer-motion@npm:12.17.0"
dependencies:
motion-dom: "npm:^12.17.0"
motion-utils: "npm:^12.12.1"
tslib: "npm:^2.4.0"
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
react:
optional: true
react-dom:
optional: true
checksum: 10c0/3262ab125650d71cd13eb9f4838da70550ea383d68a2fbd2664b05bac88b7420fe7db25911fbd30cbc237327d98a4567df34e675c8261dde559a9375e580103c
languageName: node
linkType: hard
"franc-min@npm:^6.2.0": "franc-min@npm:^6.2.0":
version: 6.2.0 version: 6.2.0
resolution: "franc-min@npm:6.2.0" resolution: "franc-min@npm:6.2.0"
@ -13552,6 +13575,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"motion-dom@npm:^12.17.0":
version: 12.17.0
resolution: "motion-dom@npm:12.17.0"
dependencies:
motion-utils: "npm:^12.12.1"
checksum: 10c0/1ec428e113f334193dcd52293c94bca21fcca97f3825521d1dafe41f6b999e8dda5013b48de2c09e2f32204f80d1d7281079ba3a142c71b8d6923a0ddb056513
languageName: node
linkType: hard
"motion-utils@npm:^12.12.1":
version: 12.12.1
resolution: "motion-utils@npm:12.12.1"
checksum: 10c0/880a174769d1be42b46cfb34af81b4a629c068d30d5cf7e07d249fbf2f5121d577482d3ea5bdc1db549c0288733e1e987efecb195fae350995270651559c6697
languageName: node
linkType: hard
"motion-utils@npm:^12.9.4": "motion-utils@npm:^12.9.4":
version: 12.9.4 version: 12.9.4
resolution: "motion-utils@npm:12.9.4" resolution: "motion-utils@npm:12.9.4"