mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 11:44:28 +08:00
feat: use tabs
wip wip wip wip
This commit is contained in:
parent
202504fd17
commit
4317f4b672
@ -5,6 +5,7 @@ import { Provider } from 'react-redux'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
|
||||
import TabsContainer from './components/Tabs/TabsContainer'
|
||||
import TopViewContainer from './components/TopView'
|
||||
import AntdProvider from './context/AntdProvider'
|
||||
import { CodeStyleProvider } from './context/CodeStyleProvider'
|
||||
@ -26,7 +27,9 @@ function App(): React.ReactElement {
|
||||
<TopViewContainer>
|
||||
<HashRouter>
|
||||
<NavigationHandler />
|
||||
<Routes />
|
||||
<TabsContainer>
|
||||
<Routes />
|
||||
</TabsContainer>
|
||||
</HashRouter>
|
||||
</TopViewContainer>
|
||||
</PersistGate>
|
||||
|
||||
@ -1,111 +1,31 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { AnimatePresence, easeInOut, motion } from 'framer-motion'
|
||||
import { useEffect } from 'react'
|
||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
|
||||
import AgentsPage from './pages/agents/AgentsPage'
|
||||
import AppsPage from './pages/apps/AppsPage'
|
||||
import FilesPage from './pages/files/FilesPage'
|
||||
import HomePage from './pages/home/HomePage'
|
||||
import KnowledgePage from './pages/knowledge/KnowledgePage'
|
||||
import LaunchpadPage from './pages/launchpad/LaunchpadPage'
|
||||
import McpServersPage from './pages/mcp-servers'
|
||||
import PaintingsRoutePage from './pages/paintings/PaintingsRoutePage'
|
||||
import SettingsPage from './pages/settings/SettingsPage'
|
||||
import TranslatePage from './pages/translate/TranslatePage'
|
||||
|
||||
const WILDCARD_ROUTES = ['/settings', '/paintings', '/mcp-servers']
|
||||
|
||||
const RouteContainer = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const isHomePage = location.pathname === '/'
|
||||
|
||||
// 获取当前路径的主路由部分
|
||||
const mainPath = WILDCARD_ROUTES.find((route) => location.pathname.startsWith(route))
|
||||
|
||||
// 使用主路由作为 key,这样同一主路由下的切换不会触发动画
|
||||
const animationKey = mainPath || location.pathname
|
||||
|
||||
useEffect(() => {
|
||||
window.api.navigation.url(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.on(IpcChannel.Navigation_Close, () => navigate('/'))
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<HomePageWrapper />
|
||||
<AnimatePresence mode="wait">
|
||||
{!isHomePage && (
|
||||
<PageContainer
|
||||
key={animationKey}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={pageVariants}
|
||||
transition={pageTransition}>
|
||||
<Routes location={location}>
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<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>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/agents" element={<AgentsPage />} />
|
||||
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
|
||||
<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 />} />
|
||||
<Route path="/launchpad" element={<LaunchpadPage />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
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;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
perspective: 1000px;
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
`
|
||||
|
||||
const pageTransition = {
|
||||
type: 'tween' as const,
|
||||
duration: 0.25,
|
||||
ease: easeInOut
|
||||
}
|
||||
|
||||
const pageVariants = {
|
||||
initial: { translateY: '100%' },
|
||||
animate: { translateY: '0%' },
|
||||
exit: { translateY: '100%' }
|
||||
}
|
||||
|
||||
export default RouteContainer
|
||||
|
||||
@ -57,23 +57,10 @@
|
||||
--navbar-background-win: rgba(20, 20, 20, 0.75);
|
||||
--navbar-background: #1f1f1f;
|
||||
|
||||
--navbar-height: 42px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--chat-background: transparent;
|
||||
--chat-background-user: rgba(255, 255, 255, 0.08);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-black);
|
||||
|
||||
--list-item-border-radius: 8px;
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
|
||||
[theme-mode='light'] {
|
||||
@ -139,17 +126,4 @@
|
||||
--chat-background-user: rgba(0, 0, 0, 0.045);
|
||||
--chat-background-assistant: transparent;
|
||||
--chat-text-user: var(--color-text);
|
||||
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
|
||||
[transparent-window='true'] {
|
||||
&[theme-mode='light'] {
|
||||
--color-list-item: rgba(255, 255, 255, 0.8);
|
||||
--color-list-item-hover: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
&[theme-mode='dark'] {
|
||||
--color-list-item: rgba(255, 255, 255, 0.1);
|
||||
--color-list-item-hover: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@use './variables.scss';
|
||||
@use './color.scss';
|
||||
@use './font.scss';
|
||||
@use './markdown.scss';
|
||||
|
||||
18
src/renderer/src/assets/styles/variables.scss
Normal file
18
src/renderer/src/assets/styles/variables.scss
Normal file
@ -0,0 +1,18 @@
|
||||
:root {
|
||||
--navbar-height: 42px;
|
||||
--sidebar-width: 50px;
|
||||
--status-bar-height: 40px;
|
||||
--input-bar-height: 100px;
|
||||
|
||||
--assistants-width: 275px;
|
||||
--topic-list-width: 275px;
|
||||
--settings-width: 250px;
|
||||
--scrollbar-width: 5px;
|
||||
|
||||
--list-item-border-radius: 15px;
|
||||
--border-width: 0.5px;
|
||||
|
||||
--main-height: calc(100vh - var(--navbar-height) - 6px);
|
||||
|
||||
--border-width: 0.5px;
|
||||
}
|
||||
237
src/renderer/src/components/Tabs/TabsContainer.tsx
Normal file
237
src/renderer/src/components/Tabs/TabsContainer.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab } from '@renderer/store/tabs'
|
||||
import {
|
||||
FileSearch,
|
||||
Folder,
|
||||
Home,
|
||||
Languages,
|
||||
LayoutGrid,
|
||||
Palette,
|
||||
Settings,
|
||||
Sparkle,
|
||||
SquareTerminal,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TabsContainerProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const getTabIcon = (tabId: string): React.ReactNode | undefined => {
|
||||
switch (tabId) {
|
||||
case 'home':
|
||||
return <Home size={14} />
|
||||
case 'agents':
|
||||
return <Sparkle size={14} />
|
||||
case 'translate':
|
||||
return <Languages size={14} />
|
||||
case 'paintings':
|
||||
return <Palette size={14} />
|
||||
case 'apps':
|
||||
return <LayoutGrid size={14} />
|
||||
case 'knowledge':
|
||||
return <FileSearch size={14} />
|
||||
case 'mcp':
|
||||
return <SquareTerminal size={14} />
|
||||
case 'files':
|
||||
return <Folder size={14} />
|
||||
case 'settings':
|
||||
return <Settings size={14} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const tabs = useAppSelector((state) => state.tabs.tabs)
|
||||
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
|
||||
const isFullscreen = useFullscreen()
|
||||
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
const segments = path.split('/')
|
||||
return segments[1] // 获取第一个路径段作为 id
|
||||
}
|
||||
|
||||
const shouldCreateTab = (path: string) => {
|
||||
if (path === '/') return false
|
||||
return !tabs.some((tab) => tab.id === getTabId(path))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const tabId = getTabId(location.pathname)
|
||||
const currentTab = tabs.find((tab) => tab.id === tabId)
|
||||
|
||||
if (!currentTab && shouldCreateTab(location.pathname)) {
|
||||
dispatch(
|
||||
addTab({
|
||||
id: tabId,
|
||||
path: location.pathname
|
||||
})
|
||||
)
|
||||
} else if (currentTab) {
|
||||
dispatch(setActiveTab(currentTab.id))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, location.pathname])
|
||||
|
||||
const closeTab = (tabId: string) => {
|
||||
const tabToClose = tabs.find((tab) => tab.id === tabId)
|
||||
if (!tabToClose) return
|
||||
|
||||
if (tabs.length === 1) return
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId)
|
||||
const lastTab = remainingTabs[remainingTabs.length - 1]
|
||||
navigate(lastTab.path)
|
||||
}
|
||||
|
||||
dispatch(removeTab(tabId))
|
||||
}
|
||||
|
||||
const handleAddTab = () => {
|
||||
navigate('/launchpad')
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TabsBar $isFullscreen={isFullscreen}>
|
||||
{tabs.map((tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => navigate(tab.path)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
||||
<TabTitle>{t(`title.${tab.id}`)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
className="close-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}>
|
||||
<X size={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
<AddTabButton onClick={handleAddTab}>
|
||||
<PlusOutlined />
|
||||
</AddTabButton>
|
||||
</TabsBar>
|
||||
<TabContent>{children}</TabContent>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-left: ${({ $isFullscreen }) => (!$isFullscreen && isMac ? '80px' : '8px')};
|
||||
-webkit-app-region: drag;
|
||||
height: var(--navbar-height);
|
||||
`
|
||||
|
||||
const Tab = styled.div<{ active?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
background: ${(props) => (props.active ? 'var(--color-background)' : 'transparent')};
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-app-region: none;
|
||||
min-width: 100px;
|
||||
transition: background 0.2s;
|
||||
.close-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
margin-right: -2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => (props.active ? 'var(--color-background)' : 'var(--color-background-soft)')};
|
||||
.close-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TabHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`
|
||||
|
||||
const TabIcon = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
const TabTitle = styled.span`
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
const CloseButton = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
`
|
||||
|
||||
const AddTabButton = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
-webkit-app-region: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
`
|
||||
|
||||
const TabContent = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
width: calc(100vw - 12px);
|
||||
margin: 6px;
|
||||
margin-top: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
export default TabsContainer
|
||||
@ -1,10 +1,8 @@
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
import { isLinux, isWindows } from '@renderer/config/constant'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { Button } from 'antd'
|
||||
import { CircleArrowLeft, X } from 'lucide-react'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styled, { keyframes } from 'styled-components'
|
||||
|
||||
type Props = PropsWithChildren & HTMLAttributes<HTMLDivElement>
|
||||
@ -31,41 +29,11 @@ export const NavbarMain: FC<Props> = ({ children, ...props }) => {
|
||||
|
||||
return (
|
||||
<NavbarMainContainer {...props} $isFullscreen={isFullscreen}>
|
||||
<CloseIcon />
|
||||
{children}
|
||||
<MacCloseIcon />
|
||||
</NavbarMainContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const MacCloseIcon = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (!isMac) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <AnimatedButton type="text" icon={<X size={18} />} onClick={() => navigate('/')} className="nodrag" />
|
||||
}
|
||||
|
||||
const CloseIcon = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
if (isMac) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => navigate('/')}
|
||||
className="nodrag"
|
||||
style={{ marginRight: 2, marginLeft: 5 }}
|
||||
icon={<CircleArrowLeft size={20} color="var(--color-icon)" style={{ marginTop: 2 }} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const NavbarContainer = styled.div`
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
@ -95,11 +63,10 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>`
|
||||
max-height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
justify-content: space-between;
|
||||
padding-left: ${({ $isFullscreen }) => ($isFullscreen ? '10px' : isMac ? '70px' : '10px')};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '12px' : isWindows ? '135px' : isLinux ? '120px' : '12px')};
|
||||
-webkit-app-region: drag;
|
||||
padding: 0 12px;
|
||||
`
|
||||
|
||||
const NavbarCenterContainer = styled.div`
|
||||
|
||||
@ -42,7 +42,6 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// Set initial theme and OS attributes on body
|
||||
document.body.setAttribute('os', isMac ? 'mac' : 'windows')
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
document.body.setAttribute('transparent-window', transparentWindow ? 'true' : 'false')
|
||||
|
||||
// if theme is old auto, then set theme to system
|
||||
// we can delete this after next big release
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { addTab, Tab } from '@renderer/store/tabs'
|
||||
import { useEffect } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
const NavigationHandler: React.FC = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const tabs = useAppSelector((state) => state.tabs.tabs)
|
||||
|
||||
const showSettingsShortcutEnabled = useAppSelector(
|
||||
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
|
||||
@ -32,6 +35,20 @@ const NavigationHandler: React.FC = () => {
|
||||
}
|
||||
)
|
||||
|
||||
// 初始化 home tab
|
||||
useEffect(() => {
|
||||
if (tabs.length === 0) {
|
||||
const homeTab: Tab = {
|
||||
id: 'home',
|
||||
titleKey: 'title.home',
|
||||
title: '',
|
||||
path: '/',
|
||||
iconType: 'home'
|
||||
}
|
||||
dispatch(addTab(homeTab))
|
||||
}
|
||||
}, [dispatch, tabs.length])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
||||
@ -12,17 +11,14 @@ import { useEffect } from 'react'
|
||||
|
||||
import { useDefaultModel } from './useAssistant'
|
||||
import useFullScreenNotice from './useFullScreenNotice'
|
||||
import { useRuntime } from './useRuntime'
|
||||
import { useSettings } from './useSettings'
|
||||
import useUpdateHandler from './useUpdateHandler'
|
||||
|
||||
export function useAppInit() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { proxyUrl, language, autoCheckUpdate, proxyMode, customCss, enableDataCollection } = useSettings()
|
||||
const { minappShow } = useRuntime()
|
||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById('spinner')?.remove()
|
||||
@ -61,15 +57,6 @@ export function useAppInit() {
|
||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
if (minappShow) {
|
||||
window.root.style.background = 'var(--color-background)'
|
||||
return
|
||||
}
|
||||
|
||||
window.root.style.background = !minappShow ? 'var(--navbar-background-mac)' : 'var(--navbar-background)'
|
||||
}, [minappShow, theme])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalAi) {
|
||||
const model = JSON.parse(import.meta.env.VITE_RENDERER_INTEGRATED_MODEL)
|
||||
|
||||
64
src/renderer/src/hooks/useTabs.ts
Normal file
64
src/renderer/src/hooks/useTabs.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab, updateTab } from '@renderer/store/tabs'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export function useTabs() {
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const tabs = useAppSelector((state) => state.tabs.tabs)
|
||||
const activeTabId = useAppSelector((state) => state.tabs.activeTabId)
|
||||
const activeTab = useAppSelector((state) => state.tabs.tabs.find((tab) => tab.id === activeTabId))
|
||||
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
const segments = path.split('/')
|
||||
return segments[1]
|
||||
}
|
||||
|
||||
const shouldCreateTab = (path: string) => {
|
||||
if (path === '/') return false
|
||||
return !tabs.some((tab) => tab.id === getTabId(path))
|
||||
}
|
||||
|
||||
const addNewTab = (tab: Tab) => {
|
||||
dispatch(addTab(tab))
|
||||
navigate(tab.path)
|
||||
}
|
||||
|
||||
const closeTab = (tabId: string) => {
|
||||
if (tabs.length === 1) return
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId)
|
||||
const lastTab = remainingTabs[remainingTabs.length - 1]
|
||||
navigate(lastTab.path)
|
||||
}
|
||||
|
||||
dispatch(removeTab(tabId))
|
||||
}
|
||||
|
||||
const switchTab = (tabId: string) => {
|
||||
const tab = tabs.find((tab) => tab.id === tabId)
|
||||
if (tab) {
|
||||
dispatch(setActiveTab(tabId))
|
||||
navigate(tab.path)
|
||||
}
|
||||
}
|
||||
|
||||
const updateCurrentTab = (updates: Partial<Tab>) => {
|
||||
dispatch(updateTab({ id: activeTabId, updates }))
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTab,
|
||||
activeTabId,
|
||||
addNewTab,
|
||||
closeTab,
|
||||
switchTab,
|
||||
getTabId,
|
||||
shouldCreateTab,
|
||||
updateCurrentTab
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "Home",
|
||||
"agents": "Agents",
|
||||
"paintings": "Paintings",
|
||||
"translate": "Translate",
|
||||
"files": "Files",
|
||||
"knowledge": "Knowledge Base",
|
||||
"apps": "Apps",
|
||||
"mcp-servers": "MCP Servers",
|
||||
"settings": "Settings",
|
||||
"launchpad": "Launchpad"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "Add to Assistant",
|
||||
"add.knowledge_base": "Knowledge Base",
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "ホーム",
|
||||
"agents": "エージェント",
|
||||
"paintings": "ペインティング",
|
||||
"translate": "翻訳",
|
||||
"files": "ファイル",
|
||||
"knowledge": "ナレッジベース",
|
||||
"apps": "アプリ",
|
||||
"mcp-servers": "MCP サーバー",
|
||||
"settings": "設定",
|
||||
"launchpad": "ランチパッド"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "アシスタントに追加",
|
||||
"add.knowledge_base": "ナレッジベース",
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "Главная",
|
||||
"agents": "Агенты",
|
||||
"paintings": "Рисунки",
|
||||
"translate": "Перевод",
|
||||
"files": "Файлы",
|
||||
"knowledge": "База знаний",
|
||||
"apps": "Приложения",
|
||||
"mcp-servers": "MCP серверы",
|
||||
"settings": "Настройки",
|
||||
"launchpad": "Запуск"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "Добавить в ассистента",
|
||||
"add.knowledge_base": "База знаний",
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "首页",
|
||||
"agents": "智能体",
|
||||
"paintings": "绘画",
|
||||
"translate": "翻译",
|
||||
"files": "文件",
|
||||
"knowledge": "知识库",
|
||||
"apps": "小程序",
|
||||
"mcp-servers": "MCP 服务器",
|
||||
"settings": "设置",
|
||||
"launchpad": "启动台"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "添加到助手",
|
||||
"add.knowledge_base": "知识库",
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": {
|
||||
"home": "主頁",
|
||||
"agents": "智能體",
|
||||
"paintings": "繪畫",
|
||||
"translate": "翻譯",
|
||||
"files": "文件",
|
||||
"knowledge": "知識庫",
|
||||
"apps": "小程序",
|
||||
"mcp-servers": "MCP 伺服器",
|
||||
"settings": "設定",
|
||||
"launchpad": "啟動台"
|
||||
},
|
||||
"agents": {
|
||||
"add.button": "新增到助手",
|
||||
"add.knowledge_base": "知識庫",
|
||||
|
||||
@ -7,7 +7,6 @@ import type { MenuProps } from 'antd'
|
||||
import { Dropdown, message } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@ -23,15 +22,8 @@ const App: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
|
||||
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
|
||||
const isPinned = pinned.some((p) => p.id === app.id)
|
||||
const isVisible = minapps.some((m) => m.id === app.id)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isHome = location.pathname === '/'
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isHome) {
|
||||
setTimeout(() => navigate('/'), 300)
|
||||
}
|
||||
|
||||
openMinappKeepAlive(app)
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
@ -113,7 +113,7 @@ const Chat: FC = () => {
|
||||
}
|
||||
|
||||
const Main = styled(Flex)`
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
height: calc(100vh - var(--navbar-height) - 50px);
|
||||
transform: translateZ(0);
|
||||
position: relative;
|
||||
`
|
||||
|
||||
@ -1,23 +1,15 @@
|
||||
import { Navbar } from '@renderer/components/app/Navbar'
|
||||
import NarrowModeIcon from '@renderer/components/Icons/NarrowModeIcon'
|
||||
import { PanelLeftIcon } from '@renderer/components/Icons/PanelIcons'
|
||||
import { NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import SearchPopup from '@renderer/components/Popups/SearchPopup'
|
||||
import { isLinux, isMac, isWindows } from '@renderer/config/constant'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useChat } from '@renderer/hooks/useChat'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
import { modelGenerating } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useShowAssistants } from '@renderer/hooks/useStore'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setNarrowMode } from '@renderer/store/settings'
|
||||
import { Tooltip } from 'antd'
|
||||
import { t } from 'i18next'
|
||||
import { LayoutGrid, Search } from 'lucide-react'
|
||||
import { PanelLeft, PanelRight, Search } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelButton from './components/SelectModelButton'
|
||||
@ -27,70 +19,31 @@ const ChatNavbar: FC = () => {
|
||||
const { activeAssistant } = useChat()
|
||||
const { assistant } = useAssistant(activeAssistant.id)
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
const isFullscreen = useFullscreen()
|
||||
const { sidebarIcons, narrowMode } = useSettings()
|
||||
const dispatch = useAppDispatch()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useShortcut('search_message', SearchPopup.show)
|
||||
|
||||
const handleNarrowModeToggle = async () => {
|
||||
await modelGenerating()
|
||||
dispatch(setNarrowMode(!narrowMode))
|
||||
}
|
||||
|
||||
return (
|
||||
<Navbar className="home-navbar">
|
||||
<NavbarContainer $isFullscreen={isFullscreen} $showSidebar={showAssistants} className="home-navbar-right">
|
||||
<HStack alignItems="center" gap={8}>
|
||||
{!showAssistants && (
|
||||
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
||||
<PanelLeftIcon size={18} expanded={false} />
|
||||
</NavbarIcon>
|
||||
)}
|
||||
<SelectModelButton assistant={assistant} />
|
||||
</HStack>
|
||||
<HStack alignItems="center" gap={8}>
|
||||
<UpdateAppButton />
|
||||
<Tooltip title={t('history.title')} mouseEnterDelay={0.8}>
|
||||
<NavbarMain className="home-navbar" style={{ minHeight: 50 }}>
|
||||
<HStack alignItems="center" gap={8}>
|
||||
<NavbarIcon onClick={() => toggleShowAssistants()}>
|
||||
{showAssistants ? <PanelLeft size={18} /> : <PanelRight size={18} />}
|
||||
</NavbarIcon>
|
||||
<SelectModelButton assistant={assistant} />
|
||||
</HStack>
|
||||
<HStack alignItems="center" gap={8}>
|
||||
<UpdateAppButton />
|
||||
{isMac && (
|
||||
<Tooltip title={t('chat.assistant.search.placeholder')} mouseEnterDelay={0.8}>
|
||||
<NarrowIcon onClick={() => SearchPopup.show()}>
|
||||
<Search size={18} />
|
||||
</NarrowIcon>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('navbar.expand')} mouseEnterDelay={0.8}>
|
||||
<NarrowIcon onClick={handleNarrowModeToggle}>
|
||||
<NarrowModeIcon isNarrowMode={narrowMode} />
|
||||
</NarrowIcon>
|
||||
</Tooltip>
|
||||
{sidebarIcons.visible.includes('minapp') && (
|
||||
<Tooltip title={t('minapp.title')} mouseEnterDelay={0.8}>
|
||||
<NarrowIcon onClick={() => navigate('/apps')}>
|
||||
<LayoutGrid size={18} />
|
||||
</NarrowIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
</NavbarContainer>
|
||||
</Navbar>
|
||||
)}
|
||||
</HStack>
|
||||
</NavbarMain>
|
||||
)
|
||||
}
|
||||
|
||||
const NavbarContainer = styled.div<{ $isFullscreen: boolean; $showSidebar: boolean }>`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: var(--navbar-height);
|
||||
max-height: var(--navbar-height);
|
||||
min-height: var(--navbar-height);
|
||||
justify-content: space-between;
|
||||
margin-left: ${({ $showSidebar }) => ($showSidebar ? '15px' : isMac ? '75px' : '15px')};
|
||||
font-weight: bold;
|
||||
color: var(--color-text-1);
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? '15px' : isWindows ? '140px' : isLinux ? '120px' : '15px')};
|
||||
-webkit-app-region: drag;
|
||||
`
|
||||
|
||||
export const NavbarIcon = styled.div`
|
||||
-webkit-app-region: none;
|
||||
border-radius: 8px;
|
||||
|
||||
@ -40,7 +40,6 @@ import styled from 'styled-components'
|
||||
import AssistantsTab from '../Tabs/AssistantsTab'
|
||||
import AssistantItem from '../Tabs/components/AssistantItem'
|
||||
import TopicsTab from '../Tabs/TopicsTab'
|
||||
import MainNavbar from './MainNavbar'
|
||||
import {
|
||||
Container,
|
||||
MainMenu,
|
||||
@ -177,7 +176,6 @@ const MainSidebar: FC = () => {
|
||||
opacity: showAssistants ? 1 : 0,
|
||||
overflow: showAssistants ? 'initial' : 'hidden'
|
||||
}}>
|
||||
<MainNavbar />
|
||||
<MainMenu>
|
||||
<SidebarSearch onSearch={setSearchValue} />
|
||||
<MainMenuItem active={isAppMenuExpanded} onClick={() => setIsAppMenuExpanded(!isAppMenuExpanded)}>
|
||||
@ -305,6 +303,7 @@ const MainContainer = styled.div`
|
||||
const AssistantContainer = styled.div`
|
||||
margin: 4px 10px;
|
||||
display: flex;
|
||||
margin-top: 0;
|
||||
`
|
||||
|
||||
const UserMenu = styled.div`
|
||||
|
||||
@ -52,7 +52,10 @@ export const Container = styled.div<{ transparent?: boolean }>`
|
||||
width: var(--assistants-width);
|
||||
max-width: var(--assistants-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
height: 100vh;
|
||||
height: var(--main-height);
|
||||
min-height: var(--main-height);
|
||||
background: var(--color-background);
|
||||
padding-top: 10px;
|
||||
`
|
||||
|
||||
export const MainMenu = styled.div`
|
||||
|
||||
118
src/renderer/src/pages/launchpad/LaunchpadPage.tsx
Normal file
118
src/renderer/src/pages/launchpad/LaunchpadPage.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { FileSearch, Folder, Languages, LayoutGrid, Palette, Sparkle, SquareTerminal } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const LaunchpadPage: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const { defaultPaintingProvider } = useSettings()
|
||||
|
||||
const appMenuItems = [
|
||||
{
|
||||
icon: <Sparkle size={32} className="icon" />,
|
||||
text: t('agents.title'),
|
||||
path: '/agents',
|
||||
bgColor: 'linear-gradient(135deg, #6366F1, #4F46E5)' // AI助手:靛蓝渐变,代表智能和科技
|
||||
},
|
||||
{
|
||||
icon: <Languages size={32} className="icon" />,
|
||||
text: t('translate.title'),
|
||||
path: '/translate',
|
||||
bgColor: 'linear-gradient(135deg, #06B6D4, #0EA5E9)' // 翻译:明亮的青蓝色,代表沟通和流畅
|
||||
},
|
||||
{
|
||||
icon: <Palette size={32} className="icon" />,
|
||||
text: t('paintings.title'),
|
||||
path: `/paintings/${defaultPaintingProvider}`,
|
||||
bgColor: 'linear-gradient(135deg, #EC4899, #F472B6)' // 绘画:活力粉色,代表创造力和艺术
|
||||
},
|
||||
{
|
||||
icon: <LayoutGrid size={32} className="icon" />,
|
||||
text: t('minapp.title'),
|
||||
path: '/apps',
|
||||
bgColor: 'linear-gradient(135deg, #8B5CF6, #A855F7)' // 小程序:紫色,代表多功能和灵活性
|
||||
},
|
||||
{
|
||||
icon: <FileSearch size={32} className="icon" />,
|
||||
text: t('knowledge.title'),
|
||||
path: '/knowledge',
|
||||
bgColor: 'linear-gradient(135deg, #10B981, #34D399)' // 知识库:翠绿色,代表生长和知识
|
||||
},
|
||||
{
|
||||
icon: <SquareTerminal size={32} className="icon" />,
|
||||
text: t('settings.mcp.title'),
|
||||
path: '/mcp-servers',
|
||||
bgColor: 'linear-gradient(135deg, #3B82F6, #60A5FA)' // MCP服务器:科技蓝,代表专业和稳定
|
||||
},
|
||||
{
|
||||
icon: <Folder size={32} className="icon" />,
|
||||
text: t('files.title'),
|
||||
path: '/files',
|
||||
bgColor: 'linear-gradient(135deg, #F59E0B, #FBBF24)' // 文件:金色,代表资源和重要性
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Grid>
|
||||
{appMenuItems.map((item) => (
|
||||
<AppIcon key={item.path} onClick={() => navigate(item.path)}>
|
||||
<IconWrapper bgColor={item.bgColor}>{item.icon}</IconWrapper>
|
||||
<AppName>{item.text}</AppName>
|
||||
</AppIcon>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 40px;
|
||||
padding: 40px;
|
||||
max-width: 800px;
|
||||
`
|
||||
|
||||
const AppIcon = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
`
|
||||
|
||||
const IconWrapper = styled.div<{ bgColor: string }>`
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
background: ${(props) => props.bgColor};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.icon {
|
||||
color: white;
|
||||
}
|
||||
`
|
||||
|
||||
const AppName = styled.div`
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
export default LaunchpadPage
|
||||
@ -1,4 +1,3 @@
|
||||
import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings'
|
||||
import { Spin } from 'antd'
|
||||
import {
|
||||
@ -38,9 +37,6 @@ const SettingsPage: FC = () => {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<NavbarMain>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('settings.title')}</NavbarCenter>
|
||||
</NavbarMain>
|
||||
<ContentContainer id="content-container">
|
||||
<SettingMenus>
|
||||
<MenuItemLink to="/settings/provider">
|
||||
|
||||
@ -22,6 +22,7 @@ import runtime from './runtime'
|
||||
import selectionStore from './selectionStore'
|
||||
import settings from './settings'
|
||||
import shortcuts from './shortcuts'
|
||||
import tabs from './tabs'
|
||||
import topics from './topics'
|
||||
import websearch from './websearch'
|
||||
|
||||
@ -42,6 +43,7 @@ const rootReducer = combineReducers({
|
||||
copilot,
|
||||
selectionStore,
|
||||
topics,
|
||||
tabs,
|
||||
// messages: messagesReducer,
|
||||
messages: newMessagesReducer,
|
||||
messageBlocks: messageBlocksReducer,
|
||||
@ -53,7 +55,7 @@ const persistedReducer = persistReducer(
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 114,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks'],
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
|
||||
migrate
|
||||
},
|
||||
rootReducer
|
||||
|
||||
57
src/renderer/src/store/tabs.ts
Normal file
57
src/renderer/src/store/tabs.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
export interface Tab {
|
||||
id: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface TabsState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string
|
||||
}
|
||||
|
||||
const initialState: TabsState = {
|
||||
tabs: [
|
||||
{
|
||||
id: 'home',
|
||||
path: '/'
|
||||
}
|
||||
],
|
||||
activeTabId: 'home'
|
||||
}
|
||||
|
||||
const tabsSlice = createSlice({
|
||||
name: 'tabs',
|
||||
initialState,
|
||||
reducers: {
|
||||
addTab: (state, action: PayloadAction<Tab>) => {
|
||||
const existingTab = state.tabs.find((tab) => tab.path === action.payload.path)
|
||||
if (!existingTab) {
|
||||
state.tabs.push(action.payload)
|
||||
}
|
||||
state.activeTabId = action.payload.id
|
||||
},
|
||||
removeTab: (state, action: PayloadAction<string>) => {
|
||||
const index = state.tabs.findIndex((tab) => tab.id === action.payload)
|
||||
if (index !== -1) {
|
||||
state.tabs.splice(index, 1)
|
||||
// 如果关闭的是当前标签页,则切换到最后一个标签页
|
||||
if (action.payload === state.activeTabId) {
|
||||
state.activeTabId = state.tabs[state.tabs.length - 1].id
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTab: (state, action: PayloadAction<{ id: string; updates: Partial<Tab> }>) => {
|
||||
const tab = state.tabs.find((tab) => tab.id === action.payload.id)
|
||||
if (tab) {
|
||||
Object.assign(tab, action.payload.updates)
|
||||
}
|
||||
},
|
||||
setActiveTab: (state, action: PayloadAction<string>) => {
|
||||
state.activeTabId = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { addTab, removeTab, setActiveTab, updateTab } = tabsSlice.actions
|
||||
export default tabsSlice.reducer
|
||||
Loading…
Reference in New Issue
Block a user