From 291f67e2b9d43a7d52ee42e13a0dae99f269c46b Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sat, 31 Jan 2026 01:09:55 +0800 Subject: [PATCH] feat: add floating provider navigation sidebar to AI providers page --- .../ProviderNav/ProviderNav.module.scss | 83 ++++++++++ .../providers/ProviderNav/ProviderNav.tsx | 126 +++++++++++++++ src/components/providers/ProviderNav/index.ts | 2 + src/components/providers/index.ts | 1 + src/pages/AiProvidersPage.tsx | 149 ++++++++++-------- 5 files changed, 294 insertions(+), 67 deletions(-) create mode 100644 src/components/providers/ProviderNav/ProviderNav.module.scss create mode 100644 src/components/providers/ProviderNav/ProviderNav.tsx create mode 100644 src/components/providers/ProviderNav/index.ts diff --git a/src/components/providers/ProviderNav/ProviderNav.module.scss b/src/components/providers/ProviderNav/ProviderNav.module.scss new file mode 100644 index 0000000..eafe1ab --- /dev/null +++ b/src/components/providers/ProviderNav/ProviderNav.module.scss @@ -0,0 +1,83 @@ +@use '../../../styles/variables' as *; + +.navContainer { + position: fixed; + right: 24px; + top: 50%; + transform: translateY(-50%); + z-index: 50; + pointer-events: auto; +} + +.navList { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 8px; + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1); +} + +.navItem { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border: none; + background: transparent; + border-radius: 10px; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.15s ease; + + &:hover { + background: rgba(0, 0, 0, 0.06); + transform: scale(1.08); + } + + &:active { + transform: scale(0.95); + } +} + +.icon { + width: 28px; + height: 28px; + object-fit: contain; +} + +.active { + background: rgba(59, 130, 246, 0.15); + box-shadow: inset 0 0 0 2px var(--primary-color); +} + +// 暗色主题适配 +:global([data-theme='dark']) { + .navList { + background: rgba(30, 30, 30, 0.7); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + } + + .navItem { + &:hover { + background: rgba(255, 255, 255, 0.1); + } + } + + .active { + background: rgba(59, 130, 246, 0.25); + } +} + +// 小屏幕隐藏悬浮导航 +@media (max-width: 1200px) { + .navContainer { + display: none; + } +} diff --git a/src/components/providers/ProviderNav/ProviderNav.tsx b/src/components/providers/ProviderNav/ProviderNav.tsx new file mode 100644 index 0000000..f716ece --- /dev/null +++ b/src/components/providers/ProviderNav/ProviderNav.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useThemeStore } from '@/stores'; +import iconGemini from '@/assets/icons/gemini.svg'; +import iconOpenaiLight from '@/assets/icons/openai-light.svg'; +import iconOpenaiDark from '@/assets/icons/openai-dark.svg'; +import iconClaude from '@/assets/icons/claude.svg'; +import iconVertex from '@/assets/icons/vertex.svg'; +import iconAmp from '@/assets/icons/amp.svg'; +import styles from './ProviderNav.module.scss'; + +export type ProviderId = 'gemini' | 'codex' | 'claude' | 'vertex' | 'ampcode' | 'openai'; + +interface ProviderNavItem { + id: ProviderId; + label: string; + getIcon: (theme: string) => string; +} + +const PROVIDERS: ProviderNavItem[] = [ + { id: 'gemini', label: 'Gemini', getIcon: () => iconGemini }, + { id: 'codex', label: 'Codex', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) }, + { id: 'claude', label: 'Claude', getIcon: () => iconClaude }, + { id: 'vertex', label: 'Vertex', getIcon: () => iconVertex }, + { id: 'ampcode', label: 'Ampcode', getIcon: () => iconAmp }, + { id: 'openai', label: 'OpenAI', getIcon: (theme) => (theme === 'dark' ? iconOpenaiDark : iconOpenaiLight) }, +]; + +export function ProviderNav() { + const resolvedTheme = useThemeStore((state) => state.resolvedTheme); + const [activeProvider, setActiveProvider] = useState(null); + const [mounted, setMounted] = useState(false); + const scrollContainerRef = useRef(null); + + useEffect(() => { + setMounted(true); + return () => setMounted(false); + }, []); + + const getScrollContainer = useCallback(() => { + if (scrollContainerRef.current) return scrollContainerRef.current; + const container = document.querySelector('.content') as HTMLElement | null; + scrollContainerRef.current = container; + return container; + }, []); + + const handleScroll = useCallback(() => { + const container = getScrollContainer(); + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const triggerPoint = containerRect.top + containerRect.height * 0.3; + + let currentActive: ProviderId | null = null; + + for (const provider of PROVIDERS) { + const element = document.getElementById(`provider-${provider.id}`); + if (element) { + const rect = element.getBoundingClientRect(); + const elementTop = rect.top; + const elementBottom = rect.bottom; + + if (triggerPoint >= elementTop && triggerPoint < elementBottom) { + currentActive = provider.id; + break; + } + } + } + + setActiveProvider(currentActive); + }, [getScrollContainer]); + + useEffect(() => { + const container = getScrollContainer(); + if (!container) return; + + handleScroll(); + container.addEventListener('scroll', handleScroll, { passive: true }); + return () => container.removeEventListener('scroll', handleScroll); + }, [handleScroll, getScrollContainer]); + + const scrollToProvider = (providerId: ProviderId) => { + const container = getScrollContainer(); + const element = document.getElementById(`provider-${providerId}`); + if (!element || !container) return; + + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const headerOffset = 24; + const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - headerOffset; + + container.scrollTo({ + top: scrollTop, + behavior: 'smooth', + }); + }; + + const navContent = ( +
+
+ {PROVIDERS.map((provider) => { + const isActive = activeProvider === provider.id; + return ( + + ); + })} +
+
+ ); + + if (!mounted) return null; + + return createPortal(navContent, document.body); +} diff --git a/src/components/providers/ProviderNav/index.ts b/src/components/providers/ProviderNav/index.ts new file mode 100644 index 0000000..2ee58b9 --- /dev/null +++ b/src/components/providers/ProviderNav/index.ts @@ -0,0 +1,2 @@ +export { ProviderNav } from './ProviderNav'; +export type { ProviderId } from './ProviderNav'; diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts index c808aef..83d85a6 100644 --- a/src/components/providers/index.ts +++ b/src/components/providers/index.ts @@ -6,6 +6,7 @@ export { OpenAISection } from './OpenAISection'; export { VertexSection } from './VertexSection'; export { ProviderList } from './ProviderList'; export { ProviderStatusBar } from './ProviderStatusBar'; +export { ProviderNav } from './ProviderNav'; export * from './hooks/useProviderStats'; export * from './types'; export * from './utils'; diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index f782ccf..1f5e35f 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -8,6 +8,7 @@ import { GeminiSection, OpenAISection, VertexSection, + ProviderNav, useProviderStats, } from '@/components/providers'; import { @@ -322,79 +323,93 @@ export function AiProvidersPage() {
{error &&
{error}
} - openEditor('/ai-providers/gemini/new')} - onEdit={(index) => openEditor(`/ai-providers/gemini/${index}`)} - onDelete={deleteGemini} - onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)} - /> +
+ openEditor('/ai-providers/gemini/new')} + onEdit={(index) => openEditor(`/ai-providers/gemini/${index}`)} + onDelete={deleteGemini} + onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)} + /> +
- openEditor('/ai-providers/codex/new')} - onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)} - onDelete={(index) => void deleteProviderEntry('codex', index)} - onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)} - /> +
+ openEditor('/ai-providers/codex/new')} + onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)} + onDelete={(index) => void deleteProviderEntry('codex', index)} + onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)} + /> +
- openEditor('/ai-providers/claude/new')} - onEdit={(index) => openEditor(`/ai-providers/claude/${index}`)} - onDelete={(index) => void deleteProviderEntry('claude', index)} - onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)} - /> +
+ openEditor('/ai-providers/claude/new')} + onEdit={(index) => openEditor(`/ai-providers/claude/${index}`)} + onDelete={(index) => void deleteProviderEntry('claude', index)} + onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)} + /> +
- openEditor('/ai-providers/vertex/new')} - onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)} - onDelete={deleteVertex} - /> +
+ openEditor('/ai-providers/vertex/new')} + onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)} + onDelete={deleteVertex} + /> +
- openEditor('/ai-providers/ampcode')} - /> +
+ openEditor('/ai-providers/ampcode')} + /> +
- openEditor('/ai-providers/openai/new')} - onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)} - onDelete={deleteOpenai} - /> +
+ openEditor('/ai-providers/openai/new')} + onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)} + onDelete={deleteOpenai} + /> +
+ + ); }