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
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca7082bb0 |
@@ -10,6 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navList {
|
.navList {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -22,7 +23,33 @@
|
|||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
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 {
|
.navItem {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -45,6 +72,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navItem.active {
|
||||||
|
&:hover {
|
||||||
|
background: transparent;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -52,8 +86,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
background: rgba(59, 130, 246, 0.15);
|
// Active highlight is rendered by the sliding indicator.
|
||||||
box-shadow: inset 0 0 0 2px var(--primary-color);
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暗色主题适配
|
// 暗色主题适配
|
||||||
@@ -70,7 +105,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.indicator {
|
||||||
background: rgba(59, 130, 246, 0.25);
|
background: rgba(59, 130, 246, 0.25);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,22 +118,29 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: calc(12px + env(safe-area-inset-bottom));
|
bottom: calc(12px + env(safe-area-inset-bottom));
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: min(520px, calc(100vw - 24px));
|
width: fit-content;
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navList {
|
.navList {
|
||||||
|
display: inline-flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
max-width: inherit;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
.navItem {
|
.navItem {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -111,3 +153,13 @@
|
|||||||
height: 22px;
|
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 { createPortal } from 'react-dom';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useThemeStore } from '@/stores';
|
import { useThemeStore } from '@/stores';
|
||||||
import iconGemini from '@/assets/icons/gemini.svg';
|
import iconGemini from '@/assets/icons/gemini.svg';
|
||||||
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||||
@@ -32,9 +33,36 @@ const HEADER_OFFSET = 24;
|
|||||||
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
type ScrollContainer = HTMLElement | (Window & typeof globalThis);
|
||||||
|
|
||||||
export function ProviderNav() {
|
export function ProviderNav() {
|
||||||
|
const location = useLocation();
|
||||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||||
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
const [activeProvider, setActiveProvider] = useState<ProviderId | null>(null);
|
||||||
const contentScrollerRef = useRef<HTMLElement | 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 getHeaderHeight = useCallback(() => {
|
||||||
const header = document.querySelector('.main-header') as HTMLElement | null;
|
const header = document.querySelector('.main-header') as HTMLElement | null;
|
||||||
@@ -96,6 +124,7 @@ export function ProviderNav() {
|
|||||||
}, [getHeaderHeight, getScrollContainer]);
|
}, [getHeaderHeight, getScrollContainer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!shouldShow) return;
|
||||||
const contentScroller = getContentScroller();
|
const contentScroller = getContentScroller();
|
||||||
|
|
||||||
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
|
// Listen to both: desktop scroll happens on `.content`; mobile uses `window`.
|
||||||
@@ -108,7 +137,35 @@ export function ProviderNav() {
|
|||||||
window.removeEventListener('resize', handleScroll);
|
window.removeEventListener('resize', handleScroll);
|
||||||
contentScroller?.removeEventListener('scroll', 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 scrollToProvider = (providerId: ProviderId) => {
|
||||||
const container = getScrollContainer();
|
const container = getScrollContainer();
|
||||||
@@ -116,6 +173,7 @@ export function ProviderNav() {
|
|||||||
if (!element || !container) return;
|
if (!element || !container) return;
|
||||||
|
|
||||||
setActiveProvider(providerId);
|
setActiveProvider(providerId);
|
||||||
|
updateIndicator(providerId);
|
||||||
|
|
||||||
// Mobile: scroll the document (header is fixed, so offset by header height).
|
// Mobile: scroll the document (header is fixed, so offset by header height).
|
||||||
if (!(container instanceof HTMLElement)) {
|
if (!(container instanceof HTMLElement)) {
|
||||||
@@ -133,18 +191,50 @@ export function ProviderNav() {
|
|||||||
container.scrollTo({ top: scrollTop, behavior: 'smooth' });
|
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 = (
|
const navContent = (
|
||||||
<div className={styles.navContainer}>
|
<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) => {
|
{PROVIDERS.map((provider) => {
|
||||||
const isActive = activeProvider === provider.id;
|
const isActive = activeProvider === provider.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
|
className={`${styles.navItem} ${isActive ? styles.active : ''}`}
|
||||||
|
ref={(node) => {
|
||||||
|
itemRefs.current[provider.id] = node;
|
||||||
|
}}
|
||||||
onClick={() => scrollToProvider(provider.id)}
|
onClick={() => scrollToProvider(provider.id)}
|
||||||
title={provider.label}
|
title={provider.label}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={provider.label}
|
||||||
|
aria-pressed={isActive}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={provider.getIcon(resolvedTheme)}
|
src={provider.getIcon(resolvedTheme)}
|
||||||
@@ -160,5 +250,7 @@ export function ProviderNav() {
|
|||||||
|
|
||||||
if (typeof document === 'undefined') return null;
|
if (typeof document === 'undefined') return null;
|
||||||
|
|
||||||
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
return createPortal(navContent, document.body);
|
return createPortal(navContent, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user