mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
feat: add floating provider navigation sidebar to AI providers page
This commit is contained in:
83
src/components/providers/ProviderNav/ProviderNav.module.scss
Normal file
83
src/components/providers/ProviderNav/ProviderNav.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
126
src/components/providers/ProviderNav/ProviderNav.tsx
Normal file
126
src/components/providers/ProviderNav/ProviderNav.tsx
Normal 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);
|
||||
}
|
||||
2
src/components/providers/ProviderNav/index.ts
Normal file
2
src/components/providers/ProviderNav/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ProviderNav } from './ProviderNav';
|
||||
export type { ProviderId } from './ProviderNav';
|
||||
@@ -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';
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GeminiSection,
|
||||
OpenAISection,
|
||||
VertexSection,
|
||||
ProviderNav,
|
||||
useProviderStats,
|
||||
} from '@/components/providers';
|
||||
import {
|
||||
@@ -322,6 +323,7 @@ export function AiProvidersPage() {
|
||||
<div className={styles.content}>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
<div id="provider-gemini">
|
||||
<GeminiSection
|
||||
configs={geminiKeys}
|
||||
keyStats={keyStats}
|
||||
@@ -334,7 +336,9 @@ export function AiProvidersPage() {
|
||||
onDelete={deleteGemini}
|
||||
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="provider-codex">
|
||||
<CodexSection
|
||||
configs={codexConfigs}
|
||||
keyStats={keyStats}
|
||||
@@ -348,7 +352,9 @@ export function AiProvidersPage() {
|
||||
onDelete={(index) => void deleteProviderEntry('codex', index)}
|
||||
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="provider-claude">
|
||||
<ClaudeSection
|
||||
configs={claudeConfigs}
|
||||
keyStats={keyStats}
|
||||
@@ -361,7 +367,9 @@ export function AiProvidersPage() {
|
||||
onDelete={(index) => void deleteProviderEntry('claude', index)}
|
||||
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="provider-vertex">
|
||||
<VertexSection
|
||||
configs={vertexConfigs}
|
||||
keyStats={keyStats}
|
||||
@@ -373,7 +381,9 @@ export function AiProvidersPage() {
|
||||
onEdit={(index) => openEditor(`/ai-providers/vertex/${index}`)}
|
||||
onDelete={deleteVertex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="provider-ampcode">
|
||||
<AmpcodeSection
|
||||
config={config?.ampcode}
|
||||
loading={loading}
|
||||
@@ -381,7 +391,9 @@ export function AiProvidersPage() {
|
||||
isSwitching={isSwitching}
|
||||
onEdit={() => openEditor('/ai-providers/ampcode')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="provider-openai">
|
||||
<OpenAISection
|
||||
configs={openaiProviders}
|
||||
keyStats={keyStats}
|
||||
@@ -396,5 +408,8 @@ export function AiProvidersPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProviderNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user