mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-27 12:51:26 +08:00
feat(discover): enhance Discover page with Tailwind CSS integration and routing improvements
- Added Tailwind CSS import to the entry point for styling. - Updated the ThemeProvider to dynamically apply Tailwind themes based on user selection. - Refactored Discover page to utilize new ROUTERS structure for better routing management. - Simplified category handling in useDiscoverCategories hook by leveraging ROUTERS_ENTRIES. - Introduced InternalCategory interface for better type management in Discover components. - Cleaned up unused code and comments for improved readability.
This commit is contained in:
parent
0d6156cc1b
commit
833ea86e82
@ -49,6 +49,7 @@ body {
|
||||
font-family: var(--font-family);
|
||||
text-rendering: optimizeLegibility;
|
||||
transition: background-color 0.3s linear;
|
||||
background-color: unset;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@ -23,6 +23,12 @@ interface ThemeProviderProps extends PropsWithChildren {
|
||||
defaultTheme?: ThemeMode
|
||||
}
|
||||
|
||||
const tailwindThemeChange = (theme: ThemeMode) => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(theme)
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// 用户设置的主题
|
||||
const { theme: settedTheme, setTheme: setSettedTheme } = useSettings()
|
||||
@ -64,7 +70,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
window.api.setTheme(settedTheme)
|
||||
// tailwindThemeChange(settedTheme)
|
||||
tailwindThemeChange(settedTheme)
|
||||
}, [settedTheme])
|
||||
|
||||
return (
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import './assets/styles/tailwind.css'
|
||||
import './assets/styles/index.scss'
|
||||
import './assets/styles/tailwind.css'
|
||||
import '@ant-design/v5-patch-for-react-19'
|
||||
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
@ -1,29 +1,29 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
|
||||
import { discoverRouters, InternalCategory } from '../routers'
|
||||
import { ROUTERS } from '../routers'
|
||||
import { InternalCategory } from '../type'
|
||||
|
||||
export interface DiscoverContentProps {
|
||||
activeTabId: string // This should be one of the CherryStoreType values, e.g., "Assistant"
|
||||
// selectedSubcategoryId: string
|
||||
activeTabId: string
|
||||
currentCategory: InternalCategory | undefined
|
||||
}
|
||||
|
||||
const DiscoverContent: React.FC<DiscoverContentProps> = ({ activeTabId, currentCategory }) => {
|
||||
const location = useLocation() // To see the current path for debugging or more complex logic
|
||||
const location = useLocation()
|
||||
|
||||
if (!currentCategory || !activeTabId) {
|
||||
return <div className="p-4 text-center">Loading: Category or Tab ID missing...</div>
|
||||
}
|
||||
|
||||
if (!activeTabId && !location.pathname.startsWith('/discover/')) {
|
||||
return <Navigate to="/discover/assistant?subcategory=all" replace /> // Fallback redirect, adjust as needed
|
||||
return <Navigate to="/discover/assistant?subcategory=all" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
{discoverRouters.map((_Route) => {
|
||||
{ROUTERS.map((_Route) => {
|
||||
if (!_Route.component) return null
|
||||
return <Route key={_Route.path} path={`/${_Route.path}`} element={<_Route.component />} />
|
||||
})}
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
SidebarProvider
|
||||
} from '@renderer/ui/sidebar'
|
||||
|
||||
import { InternalCategory } from '../hooks/useDiscoverCategories'
|
||||
import { InternalCategory } from '../type'
|
||||
|
||||
interface DiscoverSidebarProps {
|
||||
activeCategory: InternalCategory | undefined
|
||||
|
||||
@ -1,18 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { CATEGORY_REGISTRY, InternalCategory } from '../routers'
|
||||
|
||||
// 导出接口供其他文件使用
|
||||
export type { InternalCategory }
|
||||
|
||||
// Helper to find category by path
|
||||
const findCategoryByPath = (path: string | undefined): InternalCategory | undefined =>
|
||||
CATEGORY_REGISTRY.find((cat) => cat.path === path)
|
||||
|
||||
// Helper to find category by id (activeTab)
|
||||
const findCategoryById = (id: string | undefined): InternalCategory | undefined =>
|
||||
CATEGORY_REGISTRY.find((cat) => cat.id === id)
|
||||
import { ROUTERS, ROUTERS_ENTRIES } from '../routers'
|
||||
|
||||
export function useDiscoverCategories() {
|
||||
const [activeTab, setActiveTab] = useState<string>('')
|
||||
@ -34,8 +23,8 @@ export function useDiscoverCategories() {
|
||||
|
||||
// 处理基础路径重定向
|
||||
if (location.pathname === '/discover' || location.pathname === '/discover/') {
|
||||
if (CATEGORY_REGISTRY.length > 0) {
|
||||
const firstCategory = CATEGORY_REGISTRY[0]
|
||||
if (ROUTERS.length > 0) {
|
||||
const firstCategory = ROUTERS[0]
|
||||
navigate(`/discover/${firstCategory.path}?category=${firstCategory.id}&subcategory=all`, { replace: true })
|
||||
}
|
||||
return
|
||||
@ -46,14 +35,14 @@ export function useDiscoverCategories() {
|
||||
|
||||
// 如果没有 category 参数,尝试从路径推断
|
||||
if (!targetCategoryId && currentCategoryPath) {
|
||||
const categoryFromPath = findCategoryByPath(currentCategoryPath)
|
||||
const categoryFromPath = ROUTERS_ENTRIES[currentCategoryPath]
|
||||
targetCategoryId = categoryFromPath?.id || null
|
||||
}
|
||||
|
||||
// 处理无效分类重定向
|
||||
if (!targetCategoryId || !findCategoryById(targetCategoryId)) {
|
||||
if (CATEGORY_REGISTRY.length > 0) {
|
||||
const firstCategory = CATEGORY_REGISTRY[0]
|
||||
if (!targetCategoryId || !ROUTERS_ENTRIES[targetCategoryId]) {
|
||||
if (ROUTERS.length > 0) {
|
||||
const firstCategory = ROUTERS[0]
|
||||
navigate(`/discover/${firstCategory.path}?category=${firstCategory.id}&subcategory=all`, { replace: true })
|
||||
}
|
||||
return
|
||||
@ -74,7 +63,7 @@ export function useDiscoverCategories() {
|
||||
}, [location.pathname, location.search, navigate]) // 故意不包含 activeTab 和 selectedSubcategory 以避免重复渲染
|
||||
|
||||
const currentCategory = useMemo(() => {
|
||||
return findCategoryById(activeTab)
|
||||
return ROUTERS_ENTRIES[activeTab]
|
||||
}, [activeTab])
|
||||
|
||||
// 优化的 Tab 选择处理,使用 useCallback 避免重复渲染
|
||||
@ -83,7 +72,7 @@ export function useDiscoverCategories() {
|
||||
(tabId: string) => {
|
||||
if (activeTab === tabId) return // 如果已经是当前 tab,直接返回
|
||||
|
||||
const categoryToSelect = findCategoryById(tabId)
|
||||
const categoryToSelect = ROUTERS_ENTRIES[tabId]
|
||||
if (categoryToSelect?.path) {
|
||||
isUserNavigationRef.current = true
|
||||
navigate(`/discover/${categoryToSelect.path}?category=${tabId}&subcategory=all`)
|
||||
@ -97,7 +86,7 @@ export function useDiscoverCategories() {
|
||||
(subcategoryId: string) => {
|
||||
if (selectedSubcategory === subcategoryId) return // 如果已经是当前子分类,直接返回
|
||||
|
||||
const currentCatDetails = findCategoryById(activeTab)
|
||||
const currentCatDetails = ROUTERS_ENTRIES[activeTab]
|
||||
if (currentCatDetails?.path) {
|
||||
isUserNavigationRef.current = true
|
||||
navigate(`/discover/${currentCatDetails.path}?category=${activeTab}&subcategory=${subcategoryId}`)
|
||||
@ -107,7 +96,6 @@ export function useDiscoverCategories() {
|
||||
)
|
||||
|
||||
return {
|
||||
categories: CATEGORY_REGISTRY, // 直接返回静态注册表
|
||||
activeTab,
|
||||
selectedSubcategory,
|
||||
currentCategory,
|
||||
|
||||
@ -6,19 +6,14 @@ import { useTranslation } from 'react-i18next'
|
||||
import DiscoverMain from './components/DiscoverMain'
|
||||
import DiscoverSidebar from './components/DiscoverSidebar'
|
||||
import { useDiscoverCategories } from './hooks/useDiscoverCategories'
|
||||
import { ROUTERS } from './routers'
|
||||
|
||||
export default function DiscoverPage() {
|
||||
const { t } = useTranslation()
|
||||
const { categories, activeTab, selectedSubcategory, currentCategory, handleSelectTab, handleSelectSubcategory } =
|
||||
const { activeTab, selectedSubcategory, currentCategory, handleSelectTab, handleSelectSubcategory } =
|
||||
useDiscoverCategories()
|
||||
|
||||
// 使用 useMemo 优化 tabs 数据,避免每次渲染都创建新数组
|
||||
const vercelTabsData = useMemo(() => {
|
||||
return categories.map((category) => ({
|
||||
id: category.id,
|
||||
label: category.title
|
||||
}))
|
||||
}, [categories])
|
||||
const tabs = useMemo(() => ROUTERS.map((router) => ({ id: router.id, label: router.title })), [])
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
@ -29,9 +24,9 @@ export default function DiscoverPage() {
|
||||
</NavbarCenter>
|
||||
</Navbar>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="px-4 py-2">
|
||||
<Tabs tabs={vercelTabsData} onTabChange={handleSelectTab} />
|
||||
{ROUTERS.length > 0 && (
|
||||
<div className="p-2 pl-0">
|
||||
<Tabs tabs={tabs} activeTab={activeTab} onTabChange={handleSelectTab} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -45,16 +40,9 @@ export default function DiscoverPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* {!currentCategory && categories.length > 0 && (
|
||||
<div className="w-64 flex-shrink-0 border-r p-4 text-muted-foreground">Select a category...</div>
|
||||
)} */}
|
||||
|
||||
<main className="w-full overflow-hidden">
|
||||
<DiscoverMain
|
||||
activeTabId={activeTab}
|
||||
// selectedSubcategoryId={selectedSubcategory}
|
||||
currentCategory={currentCategory}
|
||||
/>
|
||||
<DiscoverMain activeTabId={activeTab} currentCategory={currentCategory} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -185,7 +185,7 @@ const AgentsPage: FC = () => {
|
||||
{/* <Navbar> */}
|
||||
{/* <NavbarCenter style={{ borderRight: 'none', justifyContent: 'space-between' }}> */}
|
||||
{/* {t('agents.title')} */}
|
||||
<div className="p-4">
|
||||
{/* <div className="flex justify-center p-2">
|
||||
<Input
|
||||
placeholder={t('common.search')}
|
||||
className="nodrag"
|
||||
@ -201,8 +201,7 @@ const AgentsPage: FC = () => {
|
||||
onPressEnter={handleSearch}
|
||||
onBlur={handleSearchInputBlur}
|
||||
/>
|
||||
<div style={{ width: 80 }} />
|
||||
</div>
|
||||
</div> */}
|
||||
{/* </NavbarCenter> */}
|
||||
{/* </Navbar> */}
|
||||
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { Navbar, NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import App from '@renderer/components/MinApp/MinApp'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useNavbarPosition } from '@renderer/hooks/useSettings'
|
||||
import { Button, Input } from 'antd'
|
||||
import { Search, SettingsIcon } from 'lucide-react'
|
||||
import React, { FC, useState } from 'react'
|
||||
@ -16,7 +14,7 @@ const AppsPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState('')
|
||||
const { minapps } = useMinapps()
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
// const { isTopNavbar } = useNavbarPosition()
|
||||
|
||||
const filteredApps = search
|
||||
? minapps.filter(
|
||||
@ -37,40 +35,40 @@ const AppsPage: FC = () => {
|
||||
|
||||
return (
|
||||
<Container onContextMenu={handleContextMenu}>
|
||||
<Navbar>
|
||||
<NavbarMain>
|
||||
{/* {t('minapp.title')} */}
|
||||
{/* <div className="p-2"> */}
|
||||
<Input
|
||||
placeholder={t('common.search')}
|
||||
className="nodrag"
|
||||
style={{
|
||||
width: '30%',
|
||||
height: 28,
|
||||
borderRadius: 15,
|
||||
position: 'absolute',
|
||||
left: '50vw',
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
size="small"
|
||||
variant="filled"
|
||||
suffix={<Search size={18} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
className="nodrag"
|
||||
icon={<SettingsIcon size={18} color="var(--color-text-2)" />}
|
||||
onClick={MinappSettingsPopup.show}
|
||||
/>
|
||||
{/* </div> */}
|
||||
</NavbarMain>
|
||||
</Navbar>
|
||||
{/* <Navbar> */}
|
||||
{/* <NavbarMain> */}
|
||||
{/* {t('minapp.title')} */}
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={t('common.search')}
|
||||
className="nodrag"
|
||||
style={{
|
||||
width: '30%',
|
||||
height: 28,
|
||||
borderRadius: 15,
|
||||
position: 'absolute',
|
||||
left: '50vw',
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
size="small"
|
||||
variant="filled"
|
||||
suffix={<Search size={18} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
className="nodrag"
|
||||
icon={<SettingsIcon size={18} color="var(--color-text-2)" />}
|
||||
onClick={MinappSettingsPopup.show}
|
||||
/>
|
||||
</div>
|
||||
{/* </NavbarMain> */}
|
||||
{/* </Navbar> */}
|
||||
<ContentContainer id="content-container">
|
||||
<MainContainer>
|
||||
<RightContainer>
|
||||
{isTopNavbar && (
|
||||
{/* {isTopNavbar && (
|
||||
<HeaderContainer>
|
||||
<Input
|
||||
placeholder={t('common.search')}
|
||||
@ -88,7 +86,7 @@ const AppsPage: FC = () => {
|
||||
onClick={() => MinappSettingsPopup.show()}
|
||||
/>
|
||||
</HeaderContainer>
|
||||
)}
|
||||
)} */}
|
||||
<AppsContainerWrapper>
|
||||
<AppsContainer style={{ height: containerHeight }}>
|
||||
{filteredApps.map((app) => (
|
||||
@ -119,15 +117,15 @@ const ContentContainer = styled.div`
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
`
|
||||
// const HeaderContainer = styled.div`
|
||||
// display: flex;
|
||||
// flex-direction: row;
|
||||
// justify-content: center;
|
||||
// align-items: center;
|
||||
// height: 60px;
|
||||
// width: 100%;
|
||||
// gap: 10px;
|
||||
// `
|
||||
|
||||
const MainContainer = styled.div`
|
||||
display: flex;
|
||||
|
||||
@ -2,18 +2,22 @@ import i18n from '@renderer/i18n'
|
||||
import { CherryStoreType } from '@renderer/types/cherryStore'
|
||||
import { lazy } from 'react'
|
||||
|
||||
export const discoverRouters = [
|
||||
export const ROUTERS = [
|
||||
{
|
||||
id: CherryStoreType.ASSISTANT,
|
||||
title: i18n.t('assistants.title'),
|
||||
path: 'assistant',
|
||||
component: lazy(() => import('./pages/agents/AgentsPage'))
|
||||
component: lazy(() => import('./pages/agents/AgentsPage')),
|
||||
hasSidebar: false, // 目前都没有侧边栏
|
||||
items: [{ id: 'all', name: `All ${i18n.t('assistants.title')}` }] // 预设 "All" 子分类
|
||||
},
|
||||
{
|
||||
id: CherryStoreType.MINI_APP,
|
||||
title: i18n.t('minapp.title'),
|
||||
path: 'mini-app',
|
||||
component: lazy(() => import('./pages/minapps/MinAppsPage'))
|
||||
component: lazy(() => import('./pages/minapps/MinAppsPage')),
|
||||
hasSidebar: false, // 目前都没有侧边栏
|
||||
items: [{ id: 'all', name: `All ${i18n.t('minapp.title')}` }] // 预设 "All" 子分类
|
||||
}
|
||||
// {
|
||||
// id: CherryStoreType.TRANSLATE,
|
||||
@ -43,20 +47,10 @@ export const discoverRouters = [
|
||||
// }
|
||||
]
|
||||
|
||||
// 静态注册表 - 避免每次渲染都重新生成
|
||||
export interface InternalCategory {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
hasSidebar?: boolean
|
||||
items: Array<{ id: string; name: string; count?: number }>
|
||||
}
|
||||
|
||||
// 预生成的分类注册表
|
||||
export const CATEGORY_REGISTRY: InternalCategory[] = discoverRouters.map((router) => ({
|
||||
id: router.id,
|
||||
title: router.title,
|
||||
path: router.path,
|
||||
hasSidebar: false, // 目前都没有侧边栏
|
||||
items: [{ id: 'all', name: `All ${router.title}` }] // 预设 "All" 子分类
|
||||
}))
|
||||
export const ROUTERS_ENTRIES = ROUTERS.reduce(
|
||||
(acc, { id, ...rest }) => {
|
||||
acc[id] = rest
|
||||
return acc
|
||||
},
|
||||
{} as Record<(typeof ROUTERS)[number]['id'], Omit<(typeof ROUTERS)[number], 'id'>>
|
||||
)
|
||||
|
||||
7
src/renderer/src/pages/discover/type.ts
Normal file
7
src/renderer/src/pages/discover/type.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface InternalCategory {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
hasSidebar?: boolean
|
||||
items: Array<{ id: string; name: string; count?: number }>
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
export enum CherryStoreType {
|
||||
ASSISTANT = 'Assistant',
|
||||
MINI_APP = 'Mini-App',
|
||||
KNOWLEDGE = 'Knowledge',
|
||||
MCP_SERVER = 'MCP-Server',
|
||||
MODEL_PROVIDER = 'Model-Provider',
|
||||
AGENT = 'Agent',
|
||||
TRANSLATE = 'Translate',
|
||||
PAINTINGS = 'Paintings',
|
||||
FILES = 'Files'
|
||||
MINI_APP = 'Mini-App'
|
||||
// KNOWLEDGE = 'Knowledge',
|
||||
// MCP_SERVER = 'MCP-Server',
|
||||
// MODEL_PROVIDER = 'Model-Provider',
|
||||
// AGENT = 'Agent',
|
||||
// TRANSLATE = 'Translate',
|
||||
// PAINTINGS = 'Paintings',
|
||||
// FILES = 'Files'
|
||||
}
|
||||
|
||||
export interface SubCategoryItem {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { cn } from '@renderer/utils'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
interface Tab {
|
||||
id: string
|
||||
@ -13,73 +13,86 @@ interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
onTabChange?: (tabId: string) => void
|
||||
}
|
||||
|
||||
const Tabs = ({
|
||||
// 提取常用的性能优化类
|
||||
const PERFORMANCE_CLASSES = 'will-change-transform [backface-visibility:hidden] [transform-style:preserve-3d]'
|
||||
|
||||
const TabsComponent = ({
|
||||
ref,
|
||||
className,
|
||||
tabs,
|
||||
activeTab: _,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
...props
|
||||
}: TabsProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [hoverStyle, setHoverStyle] = useState({})
|
||||
const [activeStyle, setActiveStyle] = useState({ left: '0px', width: '0px' })
|
||||
const [hoverStyle, setHoverStyle] = useState({ transform: 'translate3d(0px, 0px, 0px)', width: '0px' })
|
||||
const [activeStyle, setActiveStyle] = useState({ transform: 'translate3d(0px, 0px, 0px)', width: '0px' })
|
||||
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
const activeIndex = useMemo(() => {
|
||||
if (activeTab) {
|
||||
const index = tabs.findIndex((tab) => tab.id === activeTab)
|
||||
return index !== -1 ? index : 0
|
||||
}
|
||||
return 0
|
||||
}, [activeTab, tabs])
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredIndex !== null) {
|
||||
const hoveredElement = tabRefs.current[hoveredIndex]
|
||||
if (hoveredElement) {
|
||||
const { offsetLeft, offsetWidth } = hoveredElement
|
||||
setHoverStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
transform: `translate3d(${offsetLeft}px, 0px, 0px)`,
|
||||
width: `${offsetWidth}px`
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [hoveredIndex])
|
||||
|
||||
useEffect(() => {
|
||||
const activeElement = tabRefs.current[activeIndex]
|
||||
if (activeElement) {
|
||||
const { offsetLeft, offsetWidth } = activeElement
|
||||
setActiveStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`
|
||||
})
|
||||
}
|
||||
}, [activeIndex])
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const firstElement = tabRefs.current[0]
|
||||
if (firstElement) {
|
||||
const { offsetLeft, offsetWidth } = firstElement
|
||||
const activeElement = tabRefs.current[activeIndex]
|
||||
if (activeElement) {
|
||||
const { offsetLeft, offsetWidth } = activeElement
|
||||
setActiveStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
transform: `translate3d(${offsetLeft}px, 0px, 0px)`,
|
||||
width: `${offsetWidth}px`
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}, [activeIndex]) // 使用 translate3d 强制启用硬件加速
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('relative', className)} {...props}>
|
||||
<div className="relative">
|
||||
{/* Hover Highlight */}
|
||||
<div
|
||||
className="absolute flex h-[30px] items-center rounded-[6px] bg-[#0e0f1114] transition-all duration-300 ease-out dark:bg-[#ffffff1a]"
|
||||
className={cn(
|
||||
'absolute flex h-[30px] items-center rounded-[6px]',
|
||||
'bg-[#0e0f1114] dark:bg-[#ffffff1a]',
|
||||
'transition-all duration-300 ease-out',
|
||||
PERFORMANCE_CLASSES,
|
||||
hoveredIndex !== null ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
style={{
|
||||
...hoverStyle,
|
||||
opacity: hoveredIndex !== null ? 1 : 0
|
||||
transform: hoverStyle.transform,
|
||||
width: hoverStyle.width
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Active Indicator */}
|
||||
<div
|
||||
className="absolute bottom-[-6px] h-[2px] bg-[#0e0f11] transition-all duration-300 ease-out dark:bg-white"
|
||||
style={activeStyle}
|
||||
className={cn(
|
||||
'absolute bottom-[-6px] h-[2px]',
|
||||
'bg-[#0e0f11] dark:bg-white',
|
||||
'transition-all duration-300 ease-out',
|
||||
PERFORMANCE_CLASSES
|
||||
)}
|
||||
style={{
|
||||
transform: activeStyle.transform,
|
||||
width: activeStyle.width
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
@ -97,7 +110,6 @@ const Tabs = ({
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={() => {
|
||||
setActiveIndex(index)
|
||||
onTabChange?.(tab.id)
|
||||
}}>
|
||||
<div className="flex h-full items-center justify-center text-sm leading-5 font-medium whitespace-nowrap">
|
||||
@ -110,6 +122,9 @@ const Tabs = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Tabs = React.memo(TabsComponent)
|
||||
|
||||
Tabs.displayName = 'Tabs'
|
||||
|
||||
export { Tabs }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user