diff --git a/src/components/common/PageTransition.scss b/src/components/common/PageTransition.scss index e0d21e8..6ff5600 100644 --- a/src/components/common/PageTransition.scss +++ b/src/components/common/PageTransition.scss @@ -29,6 +29,18 @@ &--stacked { display: none; + + // Keep the previous layer rendered (but invisible) to avoid a blank flash when popping back. + // Older stacked layers remain `display: none` for performance. + &.page-transition__layer--stacked-keep { + display: flex; + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + opacity: 0; + will-change: transform, opacity; + } } } @@ -36,7 +48,7 @@ will-change: transform, opacity; } - &--animating &__layer:not(.page-transition__layer--exit) { + &--animating &__layer:not(.page-transition__layer--exit):not(.page-transition__layer--stacked) { position: relative; } } diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index de90795..a91e59a 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -83,20 +83,28 @@ export function PageTransition({ }; const fromIndex = resolveOrderIndex(currentLayerPathname); const toIndex = resolveOrderIndex(location.pathname); - const nextDirection: TransitionDirection = + const nextVariant: TransitionVariant = getTransitionVariant + ? getTransitionVariant(currentLayerPathname ?? '', location.pathname) + : 'vertical'; + + let nextDirection: TransitionDirection = fromIndex === null || toIndex === null || fromIndex === toIndex ? 'forward' : toIndex > fromIndex ? 'forward' : 'backward'; + // When using iOS-style stacking, history POP within the same "section" can have equal route order. + // In that case, prefer treating navigation to an existing layer as a backward (pop) transition. + if (nextVariant === 'ios' && layers.some((layer) => layer.key === location.key)) { + nextDirection = 'backward'; + } + transitionDirectionRef.current = nextDirection; - transitionVariantRef.current = getTransitionVariant - ? getTransitionVariant(currentLayerPathname ?? '', location.pathname) - : 'vertical'; + transitionVariantRef.current = nextVariant; const shouldSkipExitLayer = (() => { - if (transitionVariantRef.current !== 'ios' || nextDirection !== 'backward') return false; + if (nextVariant !== 'ios' || nextDirection !== 'backward') return false; const normalizeSegments = (pathname: string) => pathname .split('/') @@ -178,6 +186,7 @@ export function PageTransition({ getRouteOrder, getTransitionVariant, resolveScrollContainer, + layers, ]); // Run GSAP animation when animating starts @@ -322,16 +331,30 @@ export function PageTransition({ return (
- {layers.map((layer) => ( + {(() => { + const currentIndex = layers.findIndex((layer) => layer.status === 'current'); + const resolvedCurrentIndex = currentIndex === -1 ? layers.length - 1 : currentIndex; + const keepStackedIndex = layers + .slice(0, resolvedCurrentIndex) + .map((layer, index) => ({ layer, index })) + .reverse() + .find(({ layer }) => layer.status === 'stacked')?.index; + + return layers.map((layer, index) => { + const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex; + return (
{render(layer.location)}
- ))} + ); + }); + })()}
); } diff --git a/src/components/providers/ProviderNav/ProviderNav.module.scss b/src/components/providers/ProviderNav/ProviderNav.module.scss index eafe1ab..8e8021c 100644 --- a/src/components/providers/ProviderNav/ProviderNav.module.scss +++ b/src/components/providers/ProviderNav/ProviderNav.module.scss @@ -75,9 +75,39 @@ } } -// 小屏幕隐藏悬浮导航 +// 小屏幕改为底部横向浮层 @media (max-width: 1200px) { .navContainer { - display: none; + top: auto; + right: auto; + left: 50%; + bottom: calc(12px + env(safe-area-inset-bottom)); + transform: translateX(-50%); + width: min(520px, calc(100vw - 24px)); + } + + .navList { + flex-direction: row; + gap: 6px; + padding: 8px 10px; + border-radius: 999px; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .navItem { + width: 36px; + height: 36px; + border-radius: 999px; + flex: 0 0 auto; + } + + .icon { + width: 22px; + height: 22px; } } diff --git a/src/components/providers/ProviderNav/ProviderNav.tsx b/src/components/providers/ProviderNav/ProviderNav.tsx index d7f039e..c6d39f3 100644 --- a/src/components/providers/ProviderNav/ProviderNav.tsx +++ b/src/components/providers/ProviderNav/ProviderNav.tsx @@ -29,25 +29,47 @@ const PROVIDERS: ProviderNavItem[] = [ ]; const HEADER_OFFSET = 24; +type ScrollContainer = HTMLElement | (Window & typeof globalThis); export function ProviderNav() { const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const [activeProvider, setActiveProvider] = useState(null); - const scrollContainerRef = useRef(null); + const contentScrollerRef = useRef(null); + + const getHeaderHeight = useCallback(() => { + const header = document.querySelector('.main-header') as HTMLElement | null; + if (header) return header.getBoundingClientRect().height; + + const raw = getComputedStyle(document.documentElement).getPropertyValue('--header-height'); + const value = Number.parseFloat(raw); + return Number.isFinite(value) ? value : 0; + }, []); + + const getContentScroller = useCallback(() => { + if (contentScrollerRef.current && document.contains(contentScrollerRef.current)) { + return contentScrollerRef.current; + } - const getScrollContainer = useCallback(() => { - if (scrollContainerRef.current) return scrollContainerRef.current; const container = document.querySelector('.content') as HTMLElement | null; - scrollContainerRef.current = container; + contentScrollerRef.current = container; return container; }, []); + const getScrollContainer = useCallback((): ScrollContainer => { + // Mobile layout uses document scroll (layout switches at 768px); desktop uses the `.content` scroller. + const isMobile = window.matchMedia('(max-width: 768px)').matches; + if (isMobile) return window; + return getContentScroller() ?? window; + }, [getContentScroller]); + const handleScroll = useCallback(() => { const container = getScrollContainer(); if (!container) return; - const containerRect = container.getBoundingClientRect(); - const activationLine = containerRect.top + HEADER_OFFSET + 1; + const isElementScroller = container instanceof HTMLElement; + const headerHeight = isElementScroller ? 0 : getHeaderHeight(); + const containerTop = isElementScroller ? container.getBoundingClientRect().top : 0; + const activationLine = containerTop + headerHeight + HEADER_OFFSET + 1; let currentActive: ProviderId | null = null; for (const provider of PROVIDERS) { @@ -71,31 +93,44 @@ export function ProviderNav() { } setActiveProvider(currentActive); - }, [getScrollContainer]); + }, [getHeaderHeight, getScrollContainer]); useEffect(() => { - const container = getScrollContainer(); - if (!container) return; + const contentScroller = getContentScroller(); - container.addEventListener('scroll', handleScroll, { passive: true }); + // Listen to both: desktop scroll happens on `.content`; mobile uses `window`. + window.addEventListener('scroll', handleScroll, { passive: true }); + contentScroller?.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleScroll); handleScroll(); - return () => container.removeEventListener('scroll', handleScroll); - }, [handleScroll, getScrollContainer]); + return () => { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleScroll); + contentScroller?.removeEventListener('scroll', handleScroll); + }; + }, [getContentScroller, handleScroll]); const scrollToProvider = (providerId: ProviderId) => { const container = getScrollContainer(); const element = document.getElementById(`provider-${providerId}`); if (!element || !container) return; + setActiveProvider(providerId); + + // Mobile: scroll the document (header is fixed, so offset by header height). + if (!(container instanceof HTMLElement)) { + const headerHeight = getHeaderHeight(); + const elementTop = element.getBoundingClientRect().top + window.scrollY; + const target = Math.max(0, elementTop - headerHeight - HEADER_OFFSET); + window.scrollTo({ top: target, behavior: 'smooth' }); + return; + } + const containerRect = container.getBoundingClientRect(); const elementRect = element.getBoundingClientRect(); const scrollTop = container.scrollTop + (elementRect.top - containerRect.top) - HEADER_OFFSET; - setActiveProvider(providerId); - container.scrollTo({ - top: scrollTop, - behavior: 'smooth', - }); + container.scrollTo({ top: scrollTop, behavior: 'smooth' }); }; const navContent = ( diff --git a/src/pages/AiProvidersOpenAIEditLayout.tsx b/src/pages/AiProvidersOpenAIEditLayout.tsx index 70280b1..321b0c4 100644 --- a/src/pages/AiProvidersOpenAIEditLayout.tsx +++ b/src/pages/AiProvidersOpenAIEditLayout.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { providersApi } from '@/services/api'; -import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; +import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftStore } from '@/stores'; import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils'; import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types'; import type { ModelInfo } from '@/utils/models'; @@ -71,18 +71,69 @@ export function AiProvidersOpenAIEditLayout() { const connectionStatus = useAuthStore((state) => state.connectionStatus); const disableControls = connectionStatus !== 'connected'; + const config = useConfigStore((state) => state.config); const fetchConfig = useConfigStore((state) => state.fetchConfig); const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const clearCache = useConfigStore((state) => state.clearCache); + const isCacheValid = useConfigStore((state) => state.isCacheValid); - const [providers, setProviders] = useState([]); - const [form, setForm] = useState(() => buildEmptyForm()); - const [testModel, setTestModel] = useState(''); - const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); - const [testMessage, setTestMessage] = useState(''); - const [loading, setLoading] = useState(true); + const [providers, setProviders] = useState( + () => config?.openaiCompatibility ?? [] + ); + const [loading, setLoading] = useState( + () => !isCacheValid('openai-compatibility') + ); const [saving, setSaving] = useState(false); + const draftKey = useMemo(() => { + if (invalidIndexParam) return `openai:invalid:${params.index ?? 'unknown'}`; + if (editIndex === null) return 'openai:new'; + return `openai:${editIndex}`; + }, [editIndex, invalidIndexParam, params.index]); + + const draft = useOpenAIEditDraftStore((state) => state.drafts[draftKey]); + const ensureDraft = useOpenAIEditDraftStore((state) => state.ensureDraft); + const initDraft = useOpenAIEditDraftStore((state) => state.initDraft); + const clearDraft = useOpenAIEditDraftStore((state) => state.clearDraft); + const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm); + const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel); + const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus); + const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage); + + const form = draft?.form ?? buildEmptyForm(); + const testModel = draft?.testModel ?? ''; + const testStatus = draft?.testStatus ?? 'idle'; + const testMessage = draft?.testMessage ?? ''; + + const setForm: Dispatch> = useCallback( + (action) => { + setDraftForm(draftKey, action); + }, + [draftKey, setDraftForm] + ); + + const setTestModel: Dispatch> = useCallback( + (action) => { + setDraftTestModel(draftKey, action); + }, + [draftKey, setDraftTestModel] + ); + + const setTestStatus: Dispatch> = + useCallback( + (action) => { + setDraftTestStatus(draftKey, action); + }, + [draftKey, setDraftTestStatus] + ); + + const setTestMessage: Dispatch> = useCallback( + (action) => { + setDraftTestMessage(draftKey, action); + }, + [draftKey, setDraftTestMessage] + ); + const initialData = useMemo(() => { if (editIndex === null) return undefined; return providers[editIndex]; @@ -95,18 +146,26 @@ export function AiProvidersOpenAIEditLayout() { [form.modelEntries] ); + useEffect(() => { + ensureDraft(draftKey); + }, [draftKey, ensureDraft]); + const handleBack = useCallback(() => { + clearDraft(draftKey); const state = location.state as LocationState; if (state?.fromAiProviders) { navigate(-1); return; } navigate('/ai-providers', { replace: true }); - }, [location.state, navigate]); + }, [clearDraft, draftKey, location.state, navigate]); useEffect(() => { let cancelled = false; - setLoading(true); + const hasValidCache = isCacheValid('openai-compatibility'); + if (!hasValidCache) { + setLoading(true); + } fetchConfig('openai-compatibility') .then((value) => { @@ -126,14 +185,15 @@ export function AiProvidersOpenAIEditLayout() { return () => { cancelled = true; }; - }, [fetchConfig, showNotification, t]); + }, [fetchConfig, isCacheValid, showNotification, t]); useEffect(() => { if (loading) return; + if (draft?.initialized) return; if (initialData) { const modelEntries = modelsToEntries(initialData.models); - setForm({ + const seededForm: OpenAIFormState = { name: initialData.name, prefix: initialData.prefix ?? '', baseUrl: initialData.baseUrl, @@ -143,22 +203,28 @@ export function AiProvidersOpenAIEditLayout() { apiKeyEntries: initialData.apiKeyEntries?.length ? initialData.apiKeyEntries : [buildApiKeyEntry()], - }); + }; const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean); const initialTestModel = initialData.testModel && available.includes(initialData.testModel) ? initialData.testModel : available[0] || ''; - setTestModel(initialTestModel); + initDraft(draftKey, { + form: seededForm, + testModel: initialTestModel, + testStatus: 'idle', + testMessage: '', + }); } else { - setForm(buildEmptyForm()); - setTestModel(''); + initDraft(draftKey, { + form: buildEmptyForm(), + testModel: '', + testStatus: 'idle', + testMessage: '', + }); } - - setTestStatus('idle'); - setTestMessage(''); - }, [initialData, loading]); + }, [draft?.initialized, draftKey, initDraft, initialData, loading]); useEffect(() => { if (loading) return; @@ -183,32 +249,34 @@ export function AiProvidersOpenAIEditLayout() { (selectedModels: ModelInfo[]) => { if (!selectedModels.length) return; - const mergedMap = new Map(); - form.modelEntries.forEach((entry) => { - const name = entry.name.trim(); - if (!name) return; - mergedMap.set(name, { name, alias: entry.alias?.trim() || '' }); - }); - let addedCount = 0; - selectedModels.forEach((model) => { - const name = model.name.trim(); - if (!name || mergedMap.has(name)) return; - mergedMap.set(name, { name, alias: model.alias ?? '' }); - addedCount += 1; - }); + setForm((prev) => { + const mergedMap = new Map(); + prev.modelEntries.forEach((entry) => { + const name = entry.name.trim(); + if (!name) return; + mergedMap.set(name, { name, alias: entry.alias?.trim() || '' }); + }); - const mergedEntries = Array.from(mergedMap.values()); - setForm((prev) => ({ - ...prev, - modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }], - })); + selectedModels.forEach((model) => { + const name = model.name.trim(); + if (!name || mergedMap.has(name)) return; + mergedMap.set(name, { name, alias: model.alias ?? '' }); + addedCount += 1; + }); + + const mergedEntries = Array.from(mergedMap.values()); + return { + ...prev, + modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }], + }; + }); if (addedCount > 0) { showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success'); } }, - [form.modelEntries, showNotification, t] + [setForm, showNotification, t] ); const handleSave = useCallback(async () => { @@ -225,7 +293,8 @@ export function AiProvidersOpenAIEditLayout() { headers: entry.headers, })), }; - if (form.testModel) payload.testModel = form.testModel.trim(); + const resolvedTestModel = testModel.trim(); + if (resolvedTestModel) payload.testModel = resolvedTestModel; const models = entriesToModels(form.modelEntries); if (models.length) payload.models = models; @@ -256,11 +325,14 @@ export function AiProvidersOpenAIEditLayout() { form, handleBack, providers, + testModel, showNotification, t, updateConfigValue, ]); + const resolvedLoading = !draft?.initialized; + return ( { const redirect = (location.state as any)?.from?.pathname || '/'; navigate(redirect, { replace: true }); - }, 1300); + }, 1500); } else { setApiBase(storedBase || detectedBase); setManagementKey(storedKey || ''); diff --git a/src/stores/index.ts b/src/stores/index.ts index 7432fe0..797a67e 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -9,3 +9,4 @@ export { useAuthStore } from './useAuthStore'; export { useConfigStore } from './useConfigStore'; export { useModelsStore } from './useModelsStore'; export { useQuotaStore } from './useQuotaStore'; +export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore'; diff --git a/src/stores/useOpenAIEditDraftStore.ts b/src/stores/useOpenAIEditDraftStore.ts new file mode 100644 index 0000000..bab02d4 --- /dev/null +++ b/src/stores/useOpenAIEditDraftStore.ts @@ -0,0 +1,148 @@ +/** + * OpenAI provider editor draft state. + * + * Why this exists: + * - The app uses `PageTransition` with iOS-style stacked routes for `/ai-providers/*`. + * - Entering `/ai-providers/openai/.../models` creates a new route layer, so component-local state + * inside the OpenAI edit layout is not shared between the edit screen and the model picker screen. + * - This store makes the OpenAI edit draft shared across route layers keyed by provider index/new. + */ + +import type { SetStateAction } from 'react'; +import { create } from 'zustand'; +import type { OpenAIFormState } from '@/components/providers/types'; +import { buildApiKeyEntry } from '@/components/providers/utils'; + +export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error'; + +export type OpenAIEditDraft = { + initialized: boolean; + form: OpenAIFormState; + testModel: string; + testStatus: OpenAITestStatus; + testMessage: string; +}; + +interface OpenAIEditDraftState { + drafts: Record; + ensureDraft: (key: string) => void; + initDraft: (key: string, draft: Omit) => void; + setDraftForm: (key: string, action: SetStateAction) => void; + setDraftTestModel: (key: string, action: SetStateAction) => void; + setDraftTestStatus: (key: string, action: SetStateAction) => void; + setDraftTestMessage: (key: string, action: SetStateAction) => void; + clearDraft: (key: string) => void; +} + +const resolveAction = (action: SetStateAction, prev: T): T => + typeof action === 'function' ? (action as (previous: T) => T)(prev) : action; + +const buildEmptyForm = (): OpenAIFormState => ({ + name: '', + prefix: '', + baseUrl: '', + headers: [], + apiKeyEntries: [buildApiKeyEntry()], + modelEntries: [{ name: '', alias: '' }], + testModel: undefined, +}); + +const buildEmptyDraft = (): OpenAIEditDraft => ({ + initialized: false, + form: buildEmptyForm(), + testModel: '', + testStatus: 'idle', + testMessage: '', +}); + +export const useOpenAIEditDraftStore = create((set, get) => ({ + drafts: {}, + + ensureDraft: (key) => { + if (!key) return; + const existing = get().drafts[key]; + if (existing) return; + set((state) => ({ + drafts: { ...state.drafts, [key]: buildEmptyDraft() }, + })); + }, + + initDraft: (key, draft) => { + if (!key) return; + const existing = get().drafts[key]; + if (existing?.initialized) return; + set((state) => ({ + drafts: { + ...state.drafts, + [key]: { ...draft, initialized: true }, + }, + })); + }, + + setDraftForm: (key, action) => { + if (!key) return; + set((state) => { + const existing = state.drafts[key] ?? buildEmptyDraft(); + const nextForm = resolveAction(action, existing.form); + return { + drafts: { + ...state.drafts, + [key]: { ...existing, initialized: true, form: nextForm }, + }, + }; + }); + }, + + setDraftTestModel: (key, action) => { + if (!key) return; + set((state) => { + const existing = state.drafts[key] ?? buildEmptyDraft(); + const nextValue = resolveAction(action, existing.testModel); + return { + drafts: { + ...state.drafts, + [key]: { ...existing, initialized: true, testModel: nextValue }, + }, + }; + }); + }, + + setDraftTestStatus: (key, action) => { + if (!key) return; + set((state) => { + const existing = state.drafts[key] ?? buildEmptyDraft(); + const nextValue = resolveAction(action, existing.testStatus); + return { + drafts: { + ...state.drafts, + [key]: { ...existing, initialized: true, testStatus: nextValue }, + }, + }; + }); + }, + + setDraftTestMessage: (key, action) => { + if (!key) return; + set((state) => { + const existing = state.drafts[key] ?? buildEmptyDraft(); + const nextValue = resolveAction(action, existing.testMessage); + return { + drafts: { + ...state.drafts, + [key]: { ...existing, initialized: true, testMessage: nextValue }, + }, + }; + }); + }, + + clearDraft: (key) => { + if (!key) return; + set((state) => { + if (!state.drafts[key]) return state; + const next = { ...state.drafts }; + delete next[key]; + return { drafts: next }; + }); + }, +})); +