diff --git a/src/components/common/PageTransition.scss b/src/components/common/PageTransition.scss index 6b918cd..81a9e5e 100644 --- a/src/components/common/PageTransition.scss +++ b/src/components/common/PageTransition.scss @@ -14,6 +14,8 @@ gap: $spacing-lg; min-height: 0; flex: 1; + backface-visibility: hidden; + transform: translateZ(0); // During animation, exit layer uses absolute positioning &--exit { @@ -22,17 +24,15 @@ z-index: 1; overflow: hidden; pointer-events: none; + will-change: transform, opacity; } } &--animating &__layer { will-change: transform, opacity; - backface-visibility: hidden; - transform-style: preserve-3d; } - // When both layers exist, current layer also needs positioning - &--animating &__layer:not(&__layer--exit) { + &--animating &__layer:not(.page-transition__layer--exit) { position: relative; z-index: 0; } diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index 1da4636..eb0e82c 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -9,9 +9,8 @@ interface PageTransitionProps { scrollContainerRef?: React.RefObject; } -const TRANSITION_DURATION = 0.5; -const EXIT_DURATION = 0.45; -const ENTER_DELAY = 0.08; +const TRANSITION_DURATION = 0.35; +const TRAVEL_DISTANCE = 60; type LayerStatus = 'current' | 'exiting'; @@ -23,18 +22,14 @@ type Layer = { type TransitionDirection = 'forward' | 'backward'; -export function PageTransition({ - render, - getRouteOrder, - scrollContainerRef, -}: PageTransitionProps) { +export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) { const location = useLocation(); const currentLayerRef = useRef(null); const exitingLayerRef = useRef(null); + const transitionDirectionRef = useRef('forward'); const exitScrollOffsetRef = useRef(0); const [isAnimating, setIsAnimating] = useState(false); - const [transitionDirection, setTransitionDirection] = useState('forward'); const [layers, setLayers] = useState(() => [ { key: location.key, @@ -71,11 +66,11 @@ export function PageTransition({ ? 'forward' : 'backward'; - let cancelled = false; + transitionDirectionRef.current = nextDirection; + let cancelled = false; queueMicrotask(() => { if (cancelled) return; - setTransitionDirection(nextDirection); setLayers((prev) => { const prevCurrent = prev[prev.length - 1]; return [ @@ -106,17 +101,18 @@ export function PageTransition({ if (!currentLayerRef.current) return; + const currentLayerEl = currentLayerRef.current; + const exitingLayerEl = exitingLayerRef.current; + const scrollContainer = resolveScrollContainer(); const scrollOffset = exitScrollOffsetRef.current; if (scrollContainer && scrollOffset > 0) { scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' }); } - const containerHeight = scrollContainer?.clientHeight ?? 0; - const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight; - const travelDistance = Math.max(containerHeight, viewportHeight, 1); - const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance; - const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance; + const transitionDirection = transitionDirectionRef.current; + const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE; + const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE; const exitBaseY = scrollOffset ? -scrollOffset : 0; const tl = gsap.timeline({ @@ -126,43 +122,46 @@ export function PageTransition({ }, }); - // Exit animation: fly out to top (slow-to-fast) - if (exitingLayerRef.current) { - gsap.set(exitingLayerRef.current, { y: exitBaseY }); - tl.fromTo( - exitingLayerRef.current, - { y: exitBaseY, opacity: 1 }, + // Exit animation: fade out with slight movement (runs simultaneously) + if (exitingLayerEl) { + gsap.set(exitingLayerEl, { y: exitBaseY }); + tl.to( + exitingLayerEl, { y: exitBaseY + exitToY, opacity: 0, - duration: EXIT_DURATION, - ease: 'power2.in', // fast finish to clear screen + duration: TRANSITION_DURATION, + ease: 'circ.out', force3D: true, }, 0 ); } - // Enter animation: slide in from bottom (slow-to-fast) + // Enter animation: fade in with slight movement (runs simultaneously) tl.fromTo( - currentLayerRef.current, + currentLayerEl, { y: enterFromY, opacity: 0 }, { y: 0, opacity: 1, duration: TRANSITION_DURATION, - ease: 'power2.out', // smooth settle - clearProps: 'transform,opacity', + ease: 'circ.out', force3D: true, + onComplete: () => { + if (currentLayerEl) { + gsap.set(currentLayerEl, { clearProps: 'transform,opacity' }); + } + }, }, - ENTER_DELAY + 0 ); return () => { tl.kill(); - gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]); + gsap.killTweensOf([currentLayerEl, exitingLayerEl]); }; - }, [isAnimating, transitionDirection, resolveScrollContainer]); + }, [isAnimating, resolveScrollContainer]); return (
diff --git a/src/components/providers/AmpcodeSection/AmpcodeModal.tsx b/src/components/providers/AmpcodeSection/AmpcodeModal.tsx index 3b201e5..876a272 100644 --- a/src/components/providers/AmpcodeSection/AmpcodeModal.tsx +++ b/src/components/providers/AmpcodeSection/AmpcodeModal.tsx @@ -21,7 +21,7 @@ interface AmpcodeModalProps { export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) { const { t } = useTranslation(); - const { showNotification } = useNotificationStore(); + const { showNotification, showConfirmation } = useNotificationStore(); const config = useConfigStore((state) => state.config); const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const clearCache = useConfigStore((state) => state.clearCache); @@ -81,32 +81,34 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: }, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]); const clearAmpcodeUpstreamApiKey = async () => { - if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return; - setSaving(true); - setError(''); - try { - await ampcodeApi.clearUpstreamApiKey(); - const previous = config?.ampcode ?? {}; - const next: AmpcodeConfig = { ...previous }; - delete next.upstreamApiKey; - updateConfigValue('ampcode', next); - clearCache('ampcode'); - showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success'); - } catch (err: unknown) { - const message = getErrorMessage(err); - setError(message); - showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); - } finally { - setSaving(false); - } + showConfirmation({ + title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }), + message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'), + variant: 'danger', + confirmText: t('common.confirm'), + onConfirm: async () => { + setSaving(true); + setError(''); + try { + await ampcodeApi.clearUpstreamApiKey(); + const previous = config?.ampcode ?? {}; + const next: AmpcodeConfig = { ...previous }; + delete next.upstreamApiKey; + updateConfigValue('ampcode', next); + clearCache('ampcode'); + showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success'); + } catch (err: unknown) { + const message = getErrorMessage(err); + setError(message); + showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); + } finally { + setSaving(false); + } + }, + }); }; - const saveAmpcode = async () => { - if (!loaded && mappingsDirty) { - const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm')); - if (!confirmed) return; - } - + const performSaveAmpcode = async () => { setSaving(true); setError(''); try { @@ -173,6 +175,21 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: } }; + const saveAmpcode = async () => { + if (!loaded && mappingsDirty) { + showConfirmation({ + title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }), + message: t('ai_providers.ampcode_mappings_overwrite_confirm'), + variant: 'secondary', // Not dangerous, just a warning + confirmText: t('common.confirm'), + onConfirm: performSaveAmpcode, + }); + return; + } + + await performSaveAmpcode(); + }; + return ( { export const buildOpenAIModelsEndpoint = (baseUrl: string): string => { const trimmed = normalizeOpenAIBaseUrl(baseUrl); if (!trimmed) return ''; - return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; + return `${trimmed}/models`; }; export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => { diff --git a/src/components/ui/AutocompleteInput.tsx b/src/components/ui/AutocompleteInput.tsx new file mode 100644 index 0000000..86c252c --- /dev/null +++ b/src/components/ui/AutocompleteInput.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; +import { IconChevronDown } from './icons'; + +interface AutocompleteInputProps { + label?: string; + value: string; + onChange: (value: string) => void; + options: string[] | { value: string; label?: string }[]; + placeholder?: string; + disabled?: boolean; + hint?: string; + error?: string; + className?: string; + wrapperClassName?: string; + wrapperStyle?: React.CSSProperties; + id?: string; + rightElement?: ReactNode; +} + +export function AutocompleteInput({ + label, + value, + onChange, + options, + placeholder, + disabled, + hint, + error, + className = '', + wrapperClassName = '', + wrapperStyle, + id, + rightElement +}: AutocompleteInputProps) { + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const containerRef = useRef(null); + + const normalizedOptions = options.map(opt => + typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value } + ); + + const filteredOptions = normalizedOptions.filter(opt => { + const v = value.toLowerCase(); + return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v)); + }); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleInputChange = (e: ChangeEvent) => { + onChange(e.target.value); + setIsOpen(true); + setHighlightedIndex(-1); + }; + + const handleSelect = (selectedValue: string) => { + onChange(selectedValue); + setIsOpen(false); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (disabled) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!isOpen) { + setIsOpen(true); + return; + } + setHighlightedIndex(prev => + prev < filteredOptions.length - 1 ? prev + 1 : prev + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0); + } else if (e.key === 'Enter') { + if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { + e.preventDefault(); + handleSelect(filteredOptions[highlightedIndex].value); + } else if (isOpen) { + e.preventDefault(); + setIsOpen(false); + } + } else if (e.key === 'Escape') { + setIsOpen(false); + } else if (e.key === 'Tab') { + setIsOpen(false); + } + }; + + return ( +
+ {label && } +
+ setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + autoComplete="off" + style={{ paddingRight: 32 }} + /> +
!disabled && setIsOpen(!isOpen)} + > + {rightElement} + +
+ + {isOpen && filteredOptions.length > 0 && !disabled && ( +
+ {filteredOptions.map((opt, index) => ( +
handleSelect(opt.value)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent', + color: 'var(--text-primary)', + display: 'flex', + flexDirection: 'column', + fontSize: '0.9rem' + }} + onMouseEnter={() => setHighlightedIndex(index)} + > + {opt.value} + {opt.label && opt.label !== opt.value && ( + {opt.label} + )} +
+ ))} +
+ )} +
+ {hint &&
{hint}
} + {error &&
{error}
} +
+ ); +} diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx index 4417041..3fbaa62 100644 --- a/src/components/ui/ToggleSwitch.tsx +++ b/src/components/ui/ToggleSwitch.tsx @@ -4,6 +4,7 @@ interface ToggleSwitchProps { checked: boolean; onChange: (value: boolean) => void; label?: ReactNode; + ariaLabel?: string; disabled?: boolean; labelPosition?: 'left' | 'right'; } @@ -12,6 +13,7 @@ export function ToggleSwitch({ checked, onChange, label, + ariaLabel, disabled = false, labelPosition = 'right' }: ToggleSwitchProps) { @@ -25,7 +27,13 @@ export function ToggleSwitch({ return (
@@ -1047,25 +1495,27 @@ export function AuthFilesPage() { title={titleNode} extra={
- + - commitPageSizeInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } + }} />
@@ -1115,11 +1571,12 @@ export function AuthFilesPage() { {loading ? (
{t('common.loading')}
) : pageItems.length === 0 ? ( - + ) : ( -
- {pageItems.map(renderFileCard)} -
+
{pageItems.map(renderFileCard)}
)} {/* 分页 */} @@ -1137,7 +1594,7 @@ export function AuthFilesPage() { {t('auth_files.pagination_info', { current: currentPage, total: totalPages, - count: filtered.length + count: filtered.length, })} + + + } + > + {prefixProxyEditor && ( +
+ {prefixProxyEditor.loading ? ( +
+ + {t('auth_files.prefix_proxy_loading')} +
+ ) : ( + <> + {prefixProxyEditor.error && ( +
{prefixProxyEditor.error}
+ )} +
+ +