mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
1 Commits
f8c4a434ed
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca7082bb0 |
@@ -10,6 +10,7 @@
|
||||
}
|
||||
|
||||
.navList {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -22,7 +23,33 @@
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color);
|
||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
width 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
height 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 120ms ease;
|
||||
will-change: transform, width, height;
|
||||
}
|
||||
|
||||
.indicatorVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.indicatorNoTransition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -45,6 +72,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.navItem.active {
|
||||
&:hover {
|
||||
background: transparent;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -52,8 +86,9 @@
|
||||
}
|
||||
|
||||
.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
box-shadow: inset 0 0 0 2px var(--primary-color);
|
||||
// Active highlight is rendered by the sliding indicator.
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// 暗色主题适配
|
||||
@@ -70,7 +105,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
.indicator {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
}
|
||||
@@ -83,22 +118,29 @@
|
||||
left: 50%;
|
||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.navList {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
max-width: inherit;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -111,3 +153,13 @@
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indicator {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
@@ -32,9 +33,36 @@ const HEADER_OFFSET = 24;
|
||||
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
||||
|
||||
export function ProviderNav() {
|
||||
const location = useLocation();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||
const contentScrollerRef = useRef<HTMLElement | null>(null);
|
||||
const navListRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<ProviderId, HTMLButtonElement | null>>({
|
||||
gemini: null,
|
||||
codex: null,
|
||||
claude: null,
|
||||
vertex: null,
|
||||
ampcode: null,
|
||||
openai: null,
|
||||
});
|
||||
const [indicatorRect, setIndicatorRect] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [indicatorTransitionsEnabled, setIndicatorTransitionsEnabled] = useState(false);
|
||||
const indicatorHasEnabledTransitionsRef = useRef(false);
|
||||
|
||||
// Only show this quick-switch overlay on the AI Providers list page.
|
||||
// Note: The app uses iOS-style stacked page transitions inside `/ai-providers/*`,
|
||||
// so this component can stay mounted while the user is on an edit route.
|
||||
const normalizedPathname =
|
||||
location.pathname.length > 1 && location.pathname.endsWith('/')
|
||||
? location.pathname.slice(0, -1)
|
||||
: location.pathname;
|
||||
const shouldShow = normalizedPathname === '/ai-providers';
|
||||
|
||||
const getHeaderHeight = useCallback(() => {
|
||||
const header = document.querySelector('.main-header') as HTMLElement | null;
|
||||
@@ -96,6 +124,7 @@ export function ProviderNav() {
|
||||
}, [getHeaderHeight, getScrollContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
const contentScroller = getContentScroller();
|
||||
|
||||
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
|
||||
@@ -108,7 +137,35 @@ export function ProviderNav() {
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
contentScroller?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [getContentScroller, handleScroll]);
|
||||
}, [getContentScroller, handleScroll, shouldShow]);
|
||||
|
||||
const updateIndicator = useCallback((providerId: ProviderId | null) => {
|
||||
if (!providerId) {
|
||||
setIndicatorRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const itemEl = itemRefs.current[providerId];
|
||||
if (!itemEl) return;
|
||||
|
||||
setIndicatorRect({
|
||||
x: itemEl.offsetLeft,
|
||||
y: itemEl.offsetTop,
|
||||
width: itemEl.offsetWidth,
|
||||
height: itemEl.offsetHeight,
|
||||
});
|
||||
|
||||
// Avoid animating from an initial (0,0) state on first paint.
|
||||
if (!indicatorHasEnabledTransitionsRef.current) {
|
||||
indicatorHasEnabledTransitionsRef.current = true;
|
||||
requestAnimationFrame(() => setIndicatorTransitionsEnabled(true));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
updateIndicator(activeProvider);
|
||||
}, [activeProvider, shouldShow, updateIndicator]);
|
||||
|
||||
const scrollToProvider = (providerId: ProviderId) => {
|
||||
const container = getScrollContainer();
|
||||
@@ -116,6 +173,7 @@ export function ProviderNav() {
|
||||
if (!element || !container) return;
|
||||
|
||||
setActiveProvider(providerId);
|
||||
updateIndicator(providerId);
|
||||
|
||||
// Mobile: scroll the document (header is fixed, so offset by header height).
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
@@ -133,18 +191,50 @@ export function ProviderNav() {
|
||||
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShow) return;
|
||||
const handleResize = () => updateIndicator(activeProvider);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [activeProvider, shouldShow, updateIndicator]);
|
||||
|
||||
const navContent = (
|
||||
<div className={styles.navContainer}>
|
||||
<div className={styles.navList}>
|
||||
<div className={styles.navList} ref={navListRef}>
|
||||
<div
|
||||
className={[
|
||||
styles.indicator,
|
||||
indicatorRect ? styles.indicatorVisible : '',
|
||||
indicatorTransitionsEnabled ? '' : styles.indicatorNoTransition,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={
|
||||
(indicatorRect
|
||||
? ({
|
||||
transform: `translate3d(${indicatorRect.x}px, ${indicatorRect.y}px, 0)`,
|
||||
width: indicatorRect.width,
|
||||
height: indicatorRect.height,
|
||||
} satisfies CSSProperties)
|
||||
: undefined) as CSSProperties | undefined
|
||||
}
|
||||
/>
|
||||
{PROVIDERS.map((provider) => {
|
||||
const isActive = activeProvider === provider.id;
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
|
||||
ref={(node) => {
|
||||
itemRefs.current[provider.id] = node;
|
||||
}}
|
||||
onClick={() => scrollToProvider(provider.id)}
|
||||
title={provider.label}
|
||||
type="button"
|
||||
aria-label={provider.label}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
<img
|
||||
src={provider.getIcon(resolvedTheme)}
|
||||
@@ -160,5 +250,7 @@ export function ProviderNav() {
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return createPortal(navContent, document.body);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user