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 };
+ });
+ },
+}));
+