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 { 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';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user