feat(claude-edit): implement draft state management for Claude provider editor

This commit is contained in:
Supra4E8C
2026-02-17 13:31:24 +08:00
parent 3769447604
commit 4f8b421d68
3 changed files with 238 additions and 15 deletions

View File

@@ -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<ProviderKeyConfig[]>(() => config?.claudeApiKeys ?? []);
const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key'));
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
const [testModel, setTestModel] = useState('');
const [testStatus, setTestStatus] = useState<TestStatus>('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<SetStateAction<ProviderFormState>> = useCallback(
(action) => {
setDraftForm(draftKey, action);
},
[draftKey, setDraftForm]
);
const setTestModel: Dispatch<SetStateAction<string>> = useCallback(
(action) => {
setDraftTestModel(draftKey, action);
},
[draftKey, setDraftTestModel]
);
const setTestStatus: Dispatch<SetStateAction<TestStatus>> = useCallback(
(action) => {
setDraftTestStatus(draftKey, action);
},
[draftKey, setDraftTestStatus]
);
const setTestMessage: Dispatch<SetStateAction<string>> = 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,

View File

@@ -10,3 +10,4 @@ export { useConfigStore } from './useConfigStore';
export { useModelsStore } from './useModelsStore';
export { useQuotaStore } from './useQuotaStore';
export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore';
export { useClaudeEditDraftStore } from './useClaudeEditDraftStore';

View File

@@ -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<string, ClaudeEditDraft>;
ensureDraft: (key: string) => void;
initDraft: (
key: string,
draft: Omit<ClaudeEditDraft, 'initialized'>
) => void;
setDraftForm: (
key: string,
action: SetStateAction<ProviderFormState>
) => void;
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
setDraftTestStatus: (
key: string,
action: SetStateAction<ClaudeTestStatus>
) => void;
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
clearDraft: (key: string) => void;
}
const resolveAction = <T,>(action: SetStateAction<T>, 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<ClaudeEditDraftState>((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 };
});
},
}));