From f0735dbc1e0eedaab9d1fe36753c5d57b946ba2b Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sun, 1 Feb 2026 00:48:28 +0800 Subject: [PATCH] feat(store): add OpenAI edit draft state management --- src/components/common/PageTransition.tsx | 19 ++- src/pages/AiProvidersOpenAIEditLayout.tsx | 131 ++++++++++++++----- src/pages/LoginPage.tsx | 2 +- src/stores/index.ts | 1 + src/stores/useOpenAIEditDraftStore.ts | 148 ++++++++++++++++++++++ 5 files changed, 260 insertions(+), 41 deletions(-) create mode 100644 src/stores/useOpenAIEditDraftStore.ts diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index de90795..a89ceac 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 diff --git a/src/pages/AiProvidersOpenAIEditLayout.tsx b/src/pages/AiProvidersOpenAIEditLayout.tsx index 70280b1..18687a5 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'; @@ -76,13 +76,58 @@ export function AiProvidersOpenAIEditLayout() { const clearCache = useConfigStore((state) => state.clearCache); 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 [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,14 +140,19 @@ 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; @@ -130,10 +180,11 @@ export function AiProvidersOpenAIEditLayout() { 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 +194,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 +240,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 +284,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,6 +316,7 @@ export function AiProvidersOpenAIEditLayout() { form, handleBack, providers, + testModel, showNotification, t, updateConfigValue, diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index f8953c7..255af84 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -90,7 +90,7 @@ export function LoginPage() { setTimeout(() => { 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 }; + }); + }, +})); +