From 4f8b421d6838bf00fc637c2bd6315876336b340e Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Tue, 17 Feb 2026 13:31:24 +0800 Subject: [PATCH] feat(claude-edit): implement draft state management for Claude provider editor --- src/pages/AiProvidersClaudeEditLayout.tsx | 95 ++++++++++--- src/stores/index.ts | 1 + src/stores/useClaudeEditDraftStore.ts | 157 ++++++++++++++++++++++ 3 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 src/stores/useClaudeEditDraftStore.ts diff --git a/src/pages/AiProvidersClaudeEditLayout.tsx b/src/pages/AiProvidersClaudeEditLayout.tsx index 62e30b6..8c9c611 100644 --- a/src/pages/AiProvidersClaudeEditLayout.tsx +++ b/src/pages/AiProvidersClaudeEditLayout.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, useClaudeEditDraftStore, useConfigStore, useNotificationStore } from '@/stores'; import type { ProviderKeyConfig } from '@/types'; import type { ModelInfo } from '@/utils/models'; import type { ModelEntry, ProviderFormState } from '@/components/providers/types'; @@ -84,10 +84,54 @@ export function AiProvidersClaudeEditLayout() { const [configs, setConfigs] = useState(() => config?.claudeApiKeys ?? []); const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key')); const [saving, setSaving] = useState(false); - const [form, setForm] = useState(() => buildEmptyForm()); - const [testModel, setTestModel] = useState(''); - const [testStatus, setTestStatus] = useState('idle'); - const [testMessage, setTestMessage] = useState(''); + + const draftKey = useMemo(() => { + if (invalidIndexParam) return `claude:invalid:${params.index ?? 'unknown'}`; + if (editIndex === null) return 'claude:new'; + return `claude:${editIndex}`; + }, [editIndex, invalidIndexParam, params.index]); + + const draft = useClaudeEditDraftStore((state) => state.drafts[draftKey]); + const ensureDraft = useClaudeEditDraftStore((state) => state.ensureDraft); + const initDraft = useClaudeEditDraftStore((state) => state.initDraft); + const clearDraft = useClaudeEditDraftStore((state) => state.clearDraft); + const setDraftForm = useClaudeEditDraftStore((state) => state.setDraftForm); + const setDraftTestModel = useClaudeEditDraftStore((state) => state.setDraftTestModel); + const setDraftTestStatus = useClaudeEditDraftStore((state) => state.setDraftTestStatus); + const setDraftTestMessage = useClaudeEditDraftStore((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; @@ -101,14 +145,19 @@ export function AiProvidersClaudeEditLayout() { [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; @@ -139,22 +188,37 @@ export function AiProvidersClaudeEditLayout() { useEffect(() => { if (loading) return; + if (draft?.initialized) return; if (initialData) { - setForm({ + const seededForm: ProviderFormState = { ...initialData, headers: headersToEntries(initialData.headers), modelEntries: modelsToEntries(initialData.models), excludedText: excludedModelsToText(initialData.excludedModels), + }; + const available = seededForm.modelEntries.map((entry) => entry.name.trim()).filter(Boolean); + initDraft(draftKey, { + form: seededForm, + testModel: available[0] || '', + testStatus: 'idle', + testMessage: '', }); return; } - setForm(buildEmptyForm()); - }, [initialData, loading]); + initDraft(draftKey, { + form: buildEmptyForm(), + testModel: '', + testStatus: 'idle', + testMessage: '', + }); + }, [draft?.initialized, draftKey, initDraft, initialData, loading]); + + const resolvedLoading = !draft?.initialized; useEffect(() => { - if (loading) return; + if (resolvedLoading) return; if (availableModels.length === 0) { if (testModel) { @@ -170,7 +234,7 @@ export function AiProvidersClaudeEditLayout() { setTestStatus('idle'); setTestMessage(''); } - }, [availableModels, loading, testModel]); + }, [availableModels, resolvedLoading, setTestMessage, setTestModel, setTestStatus, testModel]); const mergeDiscoveredModels = useCallback( (selectedModels: ModelInfo[]) => { @@ -203,11 +267,12 @@ export function AiProvidersClaudeEditLayout() { showNotification(t('ai_providers.claude_models_fetch_added', { count: addedCount }), 'success'); } }, - [showNotification, t] + [setForm, showNotification, t] ); const handleSave = useCallback(async () => { - const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex; + const canSave = + !disableControls && !saving && !resolvedLoading && !invalidIndexParam && !invalidIndex; if (!canSave) return; setSaving(true); @@ -257,7 +322,7 @@ export function AiProvidersClaudeEditLayout() { handleBack, invalidIndex, invalidIndexParam, - loading, + resolvedLoading, saving, showNotification, t, @@ -272,7 +337,7 @@ export function AiProvidersClaudeEditLayout() { invalidIndexParam, invalidIndex, disableControls, - loading, + loading: resolvedLoading, saving, form, setForm, diff --git a/src/stores/index.ts b/src/stores/index.ts index 797a67e..6c287ca 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -10,3 +10,4 @@ export { useConfigStore } from './useConfigStore'; export { useModelsStore } from './useModelsStore'; export { useQuotaStore } from './useQuotaStore'; export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore'; +export { useClaudeEditDraftStore } from './useClaudeEditDraftStore'; diff --git a/src/stores/useClaudeEditDraftStore.ts b/src/stores/useClaudeEditDraftStore.ts new file mode 100644 index 0000000..b284d2b --- /dev/null +++ b/src/stores/useClaudeEditDraftStore.ts @@ -0,0 +1,157 @@ +/** + * Claude provider editor draft state. + * + * Why this exists: + * - The app uses `PageTransition` with iOS-style stacked routes for `/ai-providers/*`. + * - Entering `/ai-providers/claude/.../models` creates a new route layer, so component-local state + * inside the Claude edit layout is not shared between the edit screen and the model picker screen. + * - This store makes the Claude edit draft shared across route layers keyed by provider index/new. + */ + +import type { SetStateAction } from 'react'; +import { create } from 'zustand'; +import type { ProviderFormState } from '@/components/providers/types'; + +export type ClaudeTestStatus = 'idle' | 'loading' | 'success' | 'error'; + +type ClaudeEditDraft = { + initialized: boolean; + form: ProviderFormState; + testModel: string; + testStatus: ClaudeTestStatus; + testMessage: string; +}; + +interface ClaudeEditDraftState { + 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 = (): ProviderFormState => ({ + apiKey: '', + prefix: '', + baseUrl: '', + proxyUrl: '', + headers: [], + models: [], + excludedModels: [], + modelEntries: [{ name: '', alias: '' }], + excludedText: '', +}); + +const buildEmptyDraft = (): ClaudeEditDraft => ({ + initialized: false, + form: buildEmptyForm(), + testModel: '', + testStatus: 'idle', + testMessage: '', +}); + +export const useClaudeEditDraftStore = 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 }; + }); + }, +}));