feat: add floating provider navigation sidebar to AI providers page

This commit is contained in:
LTbinglingfeng
2026-01-31 01:09:55 +08:00
parent 3cdcb7a2a3
commit 291f67e2b9
5 changed files with 294 additions and 67 deletions

View File

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

View File

@@ -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<ProviderId | null>(null);
const [mounted, setMounted] = useState(false);
const scrollContainerRef = useRef<HTMLElement | null>(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 = (
<div className={styles.navContainer}>
<div className={styles.navList}>
{PROVIDERS.map((provider) => {
const isActive = activeProvider === provider.id;
return (
<button
key={provider.id}
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
onClick={() => scrollToProvider(provider.id)}
title={provider.label}
type="button"
>
<img
src={provider.getIcon(resolvedTheme)}
alt={provider.label}
className={styles.icon}
/>
</button>
);
})}
</div>
</div>
);
if (!mounted) return null;
return createPortal(navContent, document.body);
}

View File

@@ -0,0 +1,2 @@
export { ProviderNav } from './ProviderNav';
export type { ProviderId } from './ProviderNav';

View File

@@ -6,6 +6,7 @@ export { OpenAISection } from './OpenAISection';
export { VertexSection } from './VertexSection'; export { VertexSection } from './VertexSection';
export { ProviderList } from './ProviderList'; export { ProviderList } from './ProviderList';
export { ProviderStatusBar } from './ProviderStatusBar'; export { ProviderStatusBar } from './ProviderStatusBar';
export { ProviderNav } from './ProviderNav';
export * from './hooks/useProviderStats'; export * from './hooks/useProviderStats';
export * from './types'; export * from './types';
export * from './utils'; export * from './utils';

View File

@@ -8,6 +8,7 @@ import {
GeminiSection, GeminiSection,
OpenAISection, OpenAISection,
VertexSection, VertexSection,
ProviderNav,
useProviderStats, useProviderStats,
} from '@/components/providers'; } from '@/components/providers';
import { import {
@@ -322,79 +323,93 @@ export function AiProvidersPage() {
<div className={styles.content}> <div className={styles.content}>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
<GeminiSection <div id="provider-gemini">
configs={geminiKeys} <GeminiSection
keyStats={keyStats} configs={geminiKeys}
usageDetails={usageDetails} keyStats={keyStats}
loading={loading} usageDetails={usageDetails}
disableControls={disableControls} loading={loading}
isSwitching={isSwitching} disableControls={disableControls}
onAdd={() => openEditor('/ai-providers/gemini/new')} isSwitching={isSwitching}
onEdit={(index) => openEditor(`/ai-providers/gemini/${index}`)} onAdd={() => openEditor('/ai-providers/gemini/new')}
onDelete={deleteGemini} onEdit={(index) => openEditor(`/ai-providers/gemini/${index}`)}
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)} onDelete={deleteGemini}
/> onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
/>
</div>
<CodexSection <div id="provider-codex">
configs={codexConfigs} <CodexSection
keyStats={keyStats} configs={codexConfigs}
usageDetails={usageDetails} keyStats={keyStats}
loading={loading} usageDetails={usageDetails}
disableControls={disableControls} loading={loading}
isSwitching={isSwitching} disableControls={disableControls}
resolvedTheme={resolvedTheme} isSwitching={isSwitching}
onAdd={() => openEditor('/ai-providers/codex/new')} resolvedTheme={resolvedTheme}
onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)} onAdd={() => openEditor('/ai-providers/codex/new')}
onDelete={(index) => void deleteProviderEntry('codex', index)} onEdit={(index) => openEditor(`/ai-providers/codex/${index}`)}
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)} onDelete={(index) => void deleteProviderEntry('codex', index)}
/> onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
/>
</div>
<ClaudeSection <div id="provider-claude">
configs={claudeConfigs} <ClaudeSection
keyStats={keyStats} configs={claudeConfigs}
usageDetails={usageDetails} keyStats={keyStats}
loading={loading} usageDetails={usageDetails}
disableControls={disableControls} loading={loading}
isSwitching={isSwitching} disableControls={disableControls}
onAdd={() => openEditor('/ai-providers/claude/new')} isSwitching={isSwitching}
onEdit={(index) => openEditor(`/ai-providers/claude/${index}`)} onAdd={() => openEditor('/ai-providers/claude/new')}
onDelete={(index) => void deleteProviderEntry('claude', index)} onEdit={(index) => openEditor(`/ai-providers/claude/${index}`)}
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)} onDelete={(index) => void deleteProviderEntry('claude', index)}
/> onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
/>
</div>
<VertexSection <div id="provider-vertex">
configs={vertexConfigs} <VertexSection
keyStats={keyStats} configs={vertexConfigs}
usageDetails={usageDetails} keyStats={keyStats}
loading={loading} usageDetails={usageDetails}
disableControls={disableControls} loading={loading}
isSwitching={isSwitching} disableControls={disableControls}
onAdd={() => openEditor('/ai-providers/vertex/new')} isSwitching={isSwitching}
onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)} onAdd={() => openEditor('/ai-providers/vertex/new')}
onDelete={deleteVertex} onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)}
/> onDelete={deleteVertex}
/>
</div>
<AmpcodeSection <div id="provider-ampcode">
config={config?.ampcode} <AmpcodeSection
loading={loading} config={config?.ampcode}
disableControls={disableControls} loading={loading}
isSwitching={isSwitching} disableControls={disableControls}
onEdit={() => openEditor('/ai-providers/ampcode')} isSwitching={isSwitching}
/> onEdit={() => openEditor('/ai-providers/ampcode')}
/>
</div>
<OpenAISection <div id="provider-openai">
configs={openaiProviders} <OpenAISection
keyStats={keyStats} configs={openaiProviders}
usageDetails={usageDetails} keyStats={keyStats}
loading={loading} usageDetails={usageDetails}
disableControls={disableControls} loading={loading}
isSwitching={isSwitching} disableControls={disableControls}
resolvedTheme={resolvedTheme} isSwitching={isSwitching}
onAdd={() => openEditor('/ai-providers/openai/new')} resolvedTheme={resolvedTheme}
onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)} onAdd={() => openEditor('/ai-providers/openai/new')}
onDelete={deleteOpenai} onEdit={(index) => openEditor(`/ai-providers/openai/${index}`)}
/> onDelete={deleteOpenai}
/>
</div>
</div> </div>
<ProviderNav />
</div> </div>
); );
} }