diff --git a/src/hooks/useUnsavedChangesGuard.ts b/src/hooks/useUnsavedChangesGuard.ts new file mode 100644 index 0000000..4eddb86 --- /dev/null +++ b/src/hooks/useUnsavedChangesGuard.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { BlockerFunction } from 'react-router'; +import { useBlocker } from 'react-router'; +import { useNotificationStore } from '@/stores'; + +type ConfirmationVariant = 'danger' | 'primary' | 'secondary'; + +export type UnsavedChangesDialog = { + title: string; + message: string; + confirmText: string; + cancelText: string; + variant?: ConfirmationVariant; +}; + +export type UseUnsavedChangesGuardOptions = { + enabled?: boolean; + shouldBlock: boolean | BlockerFunction; + dialog: UnsavedChangesDialog; +}; + +export function useUnsavedChangesGuard(options: UseUnsavedChangesGuardOptions) { + const { enabled = true, shouldBlock, dialog } = options; + const { showConfirmation } = useNotificationStore(); + const lastBlockedRef = useRef(''); + + const shouldBlockFunction = useCallback( + (args) => { + if (!enabled) return false; + return typeof shouldBlock === 'function' ? shouldBlock(args) : shouldBlock; + }, + [enabled, shouldBlock] + ); + + const blocker = useBlocker(shouldBlockFunction); + + const blockedKey = useMemo(() => { + if (blocker.state !== 'blocked' || !blocker.location) return ''; + return `${blocker.location.pathname}${blocker.location.search}${blocker.location.hash}`; + }, [blocker.location, blocker.state]); + + useEffect(() => { + if (blocker.state !== 'blocked') { + lastBlockedRef.current = ''; + return; + } + + if (!blockedKey || lastBlockedRef.current === blockedKey) { + return; + } + lastBlockedRef.current = blockedKey; + + showConfirmation({ + title: dialog.title, + message: dialog.message, + confirmText: dialog.confirmText, + cancelText: dialog.cancelText, + variant: dialog.variant ?? 'danger', + onConfirm: () => blocker.proceed(), + onCancel: () => blocker.reset(), + }); + }, [blockedKey, blocker, dialog, showConfirmation]); +} + diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a65456b..621be37 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -5,6 +5,8 @@ "back": "Back", "cancel": "Cancel", "confirm": "Confirm", + "leave": "Leave", + "stay": "Stay", "save": "Save", "delete": "Delete", "edit": "Edit", @@ -48,7 +50,9 @@ "custom_headers_value_placeholder": "Header value", "model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022", "model_alias_placeholder": "Model alias (optional)", - "invalid_provider_index": "Invalid provider index." + "invalid_provider_index": "Invalid provider index.", + "unsaved_changes_title": "Unsaved changes", + "unsaved_changes_message": "You have unsaved changes. Leaving now will discard them. Do you want to leave?" }, "title": { "main": "CLI Proxy API Management Center", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 171b9cf..b20eba6 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -5,6 +5,8 @@ "back": "Назад", "cancel": "Отмена", "confirm": "Подтвердить", + "leave": "Уйти", + "stay": "Остаться", "save": "Сохранить", "delete": "Удалить", "edit": "Редактировать", @@ -48,7 +50,9 @@ "custom_headers_value_placeholder": "Значение заголовка", "model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022", "model_alias_placeholder": "Псевдоним модели (необязательно)", - "invalid_provider_index": "Неверный индекс провайдера." + "invalid_provider_index": "Неверный индекс провайдера.", + "unsaved_changes_title": "Несохранённые изменения", + "unsaved_changes_message": "У вас есть несохранённые изменения. Если вы уйдёте, они будут потеряны. Выйти?" }, "title": { "main": "Центр управления CLI Proxy API", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9e412a1..7c8b692 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -5,6 +5,8 @@ "back": "返回", "cancel": "取消", "confirm": "确认", + "leave": "离开", + "stay": "继续编辑", "save": "保存", "delete": "删除", "edit": "编辑", @@ -48,7 +50,9 @@ "custom_headers_value_placeholder": "Header 值", "model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022", "model_alias_placeholder": "模型别名 (可选)", - "invalid_provider_index": "无效的提供商索引。" + "invalid_provider_index": "无效的提供商索引。", + "unsaved_changes_title": "未保存的更改", + "unsaved_changes_message": "你有未保存的更改,离开后将丢失这些更改。确定要离开吗?" }, "title": { "main": "CLI Proxy API Management Center", diff --git a/src/pages/AiProvidersAmpcodeEditPage.tsx b/src/pages/AiProvidersAmpcodeEditPage.tsx index 49bda10..a416e8a 100644 --- a/src/pages/AiProvidersAmpcodeEditPage.tsx +++ b/src/pages/AiProvidersAmpcodeEditPage.tsx @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input'; import { ModelInputList } from '@/components/ui/ModelInputList'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; +import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard'; import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; import { ampcodeApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; @@ -24,6 +25,23 @@ const getErrorMessage = (err: unknown) => { return ''; }; +const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>) => + (entries ?? []).reduce>((acc, entry) => { + const from = String(entry?.name ?? '').trim(); + const to = String(entry?.alias ?? '').trim(); + if (!from && !to) return acc; + acc.push({ from, to }); + return acc; + }, []); + +const buildAmpcodeSignature = (form: AmpcodeFormState) => + JSON.stringify({ + upstreamUrl: String(form.upstreamUrl ?? '').trim(), + upstreamApiKey: String(form.upstreamApiKey ?? '').trim(), + forceModelMappings: Boolean(form.forceModelMappings), + modelMappings: normalizeMappingEntries(form.mappingEntries), + }); + export function AiProvidersAmpcodeEditPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -42,6 +60,9 @@ export function AiProvidersAmpcodeEditPage() { const [mappingsDirty, setMappingsDirty] = useState(false); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); + const [baselineSignature, setBaselineSignature] = useState(() => + buildAmpcodeSignature(buildAmpcodeFormState(null)) + ); const initializedRef = useRef(false); const mountedRef = useRef(false); @@ -83,7 +104,9 @@ export function AiProvidersAmpcodeEditPage() { setLoaded(false); setMappingsDirty(false); setError(''); - setForm(buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null)); + const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null); + setForm(initialForm); + setBaselineSignature(buildAmpcodeSignature(initialForm)); void (async () => { try { @@ -93,7 +116,9 @@ export function AiProvidersAmpcodeEditPage() { setLoaded(true); updateConfigValue('ampcode', ampcode); clearCache('ampcode'); - setForm(buildAmpcodeFormState(ampcode)); + const nextForm = buildAmpcodeFormState(ampcode); + setForm(nextForm); + setBaselineSignature(buildAmpcodeSignature(nextForm)); } catch (err: unknown) { if (!mountedRef.current) return; setError(getErrorMessage(err) || t('notification.refresh_failed')); @@ -105,6 +130,23 @@ export function AiProvidersAmpcodeEditPage() { })(); }, [clearCache, t, updateConfigValue]); + const currentSignature = useMemo(() => buildAmpcodeSignature(form), [form]); + const isDirty = baselineSignature !== currentSignature; + const canGuard = !loading && !saving; + + useUnsavedChangesGuard({ + enabled: canGuard, + shouldBlock: ({ currentLocation, nextLocation }) => + isDirty && currentLocation.pathname !== nextLocation.pathname, + dialog: { + title: t('common.unsaved_changes_title'), + message: t('common.unsaved_changes_message'), + confirmText: t('common.leave'), + cancelText: t('common.stay'), + variant: 'danger', + }, + }); + const clearAmpcodeUpstreamApiKey = async () => { showConfirmation({ title: t('ai_providers.ampcode_clear_upstream_api_key_title', { diff --git a/src/pages/AiProvidersClaudeEditLayout.tsx b/src/pages/AiProvidersClaudeEditLayout.tsx index be9505d..cc9296c 100644 --- a/src/pages/AiProvidersClaudeEditLayout.tsx +++ b/src/pages/AiProvidersClaudeEditLayout.tsx @@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard'; import { providersApi } from '@/services/api'; 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'; -import { buildHeaderObject, headersToEntries } from '@/utils/headers'; +import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers'; import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils'; import { modelsToEntries } from '@/components/ui/modelInputListUtils'; @@ -62,6 +63,59 @@ const getErrorMessage = (err: unknown) => { return ''; }; +const normalizeHeaderEntries = (entries: HeaderEntry[]) => + (entries ?? []) + .map((entry) => ({ + key: String(entry?.key ?? '').trim(), + value: String(entry?.value ?? '').trim(), + })) + .filter((entry) => entry.key || entry.value) + .sort((a, b) => { + const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase()); + if (byKey !== 0) return byKey; + return a.value.localeCompare(b.value); + }); + +const normalizeClaudeModelEntries = (entries: Array<{ name: string; alias: string }>) => + (entries ?? []).reduce>((acc, entry) => { + const name = String(entry?.name ?? '').trim(); + let alias = String(entry?.alias ?? '').trim(); + if (name) { + alias = alias || name; + } + if (!name && !alias) return acc; + acc.push({ name, alias }); + return acc; + }, []); + +const normalizeCloakConfig = (cloak: ProviderFormState['cloak']) => { + if (!cloak) return null; + const mode = String(cloak.mode ?? '').trim().toLowerCase() || 'auto'; + const strictMode = Boolean(cloak.strictMode); + const sensitiveWords = Array.isArray(cloak.sensitiveWords) + ? cloak.sensitiveWords.map((word) => String(word ?? '').trim()).filter(Boolean) + : []; + return { + mode, + strictMode, + sensitiveWords: sensitiveWords.length ? sensitiveWords : null, + }; +}; + +const buildClaudeSignature = (form: ProviderFormState) => + JSON.stringify({ + apiKey: String(form.apiKey ?? '').trim(), + priority: + form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null, + prefix: String(form.prefix ?? '').trim(), + baseUrl: String(form.baseUrl ?? '').trim(), + proxyUrl: String(form.proxyUrl ?? '').trim(), + headers: normalizeHeaderEntries(form.headers), + models: normalizeClaudeModelEntries(form.modelEntries), + excludedModels: parseExcludedModels(form.excludedText ?? ''), + cloak: normalizeCloakConfig(form.cloak), + }); + export function AiProvidersClaudeEditLayout() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -85,6 +139,8 @@ export function AiProvidersClaudeEditLayout() { const [configs, setConfigs] = useState(() => config?.claudeApiKeys ?? []); const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key')); const [saving, setSaving] = useState(false); + const [baselineDraftKey, setBaselineDraftKey] = useState(''); + const [baselineSignature, setBaselineSignature] = useState(''); const draftKey = useMemo(() => { if (invalidIndexParam) return `claude:invalid:${params.index ?? 'unknown'}`; @@ -151,14 +207,19 @@ export function AiProvidersClaudeEditLayout() { }, [draftKey, ensureDraft]); const handleBack = useCallback(() => { - clearDraft(draftKey); const state = location.state as LocationState; if (state?.fromAiProviders) { navigate(-1); return; } navigate('/ai-providers', { replace: true }); - }, [clearDraft, draftKey, location.state, navigate]); + }, [location.state, navigate]); + + useEffect(() => { + return () => { + clearDraft(draftKey); + }; + }, [clearDraft, draftKey]); useEffect(() => { let cancelled = false; @@ -217,6 +278,40 @@ export function AiProvidersClaudeEditLayout() { }, [draft?.initialized, draftKey, initDraft, initialData, loading]); const resolvedLoading = !draft?.initialized; + const currentSignature = useMemo(() => buildClaudeSignature(form), [form]); + + useEffect(() => { + if (resolvedLoading) return; + if (baselineDraftKey === draftKey) return; + setBaselineDraftKey(draftKey); + setBaselineSignature(currentSignature); + }, [baselineDraftKey, currentSignature, draftKey, resolvedLoading]); + + const isDirty = baselineDraftKey === draftKey && baselineSignature !== currentSignature; + const editorRootPath = useMemo(() => { + if (hasIndexParam) { + return `/ai-providers/claude/${params.index ?? ''}`; + } + return '/ai-providers/claude/new'; + }, [hasIndexParam, params.index]); + const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex; + + useUnsavedChangesGuard({ + enabled: canGuard, + shouldBlock: ({ nextLocation }) => { + const nextPath = nextLocation.pathname; + const isWithinRoot = + nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`); + return isDirty && !isWithinRoot; + }, + dialog: { + title: t('common.unsaved_changes_title'), + message: t('common.unsaved_changes_message'), + confirmText: t('common.leave'), + cancelText: t('common.stay'), + variant: 'danger', + }, + }); useEffect(() => { if (resolvedLoading) return; diff --git a/src/pages/AiProvidersCodexEditPage.tsx b/src/pages/AiProvidersCodexEditPage.tsx index ddc91f7..7d98bbb 100644 --- a/src/pages/AiProvidersCodexEditPage.tsx +++ b/src/pages/AiProvidersCodexEditPage.tsx @@ -9,11 +9,12 @@ import { ModelInputList } from '@/components/ui/ModelInputList'; import { Modal } from '@/components/ui/Modal'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; +import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard'; import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; import { modelsApi, providersApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import type { ProviderKeyConfig } from '@/types'; -import { buildHeaderObject, headersToEntries } from '@/utils/headers'; +import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers'; import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils'; import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils'; import type { ProviderFormState } from '@/components/providers'; @@ -49,6 +50,45 @@ const getErrorMessage = (err: unknown) => { return ''; }; +const normalizeHeaderEntries = (entries: HeaderEntry[]) => + (entries ?? []) + .map((entry) => ({ + key: String(entry?.key ?? '').trim(), + value: String(entry?.value ?? '').trim(), + })) + .filter((entry) => entry.key || entry.value) + .sort((a, b) => { + const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase()); + if (byKey !== 0) return byKey; + return a.value.localeCompare(b.value); + }); + +const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) => + (entries ?? []).reduce>((acc, entry) => { + const name = String(entry?.name ?? '').trim(); + let alias = String(entry?.alias ?? '').trim(); + if (name && alias === name) { + alias = ''; + } + if (!name && !alias) return acc; + acc.push({ name, alias }); + return acc; + }, []); + +const buildCodexSignature = (form: ProviderFormState) => + JSON.stringify({ + apiKey: String(form.apiKey ?? '').trim(), + priority: + form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null, + prefix: String(form.prefix ?? '').trim(), + baseUrl: String(form.baseUrl ?? '').trim(), + websockets: Boolean(form.websockets), + proxyUrl: String(form.proxyUrl ?? '').trim(), + headers: normalizeHeaderEntries(form.headers), + models: normalizeModelEntries(form.modelEntries), + excludedModels: parseExcludedModels(form.excludedText ?? ''), + }); + export function AiProvidersCodexEditPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -68,6 +108,7 @@ export function AiProvidersCodexEditPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [form, setForm] = useState(() => buildEmptyForm()); + const [baselineSignature, setBaselineSignature] = useState(() => buildCodexSignature(buildEmptyForm())); const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false); const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState(''); @@ -142,17 +183,38 @@ export function AiProvidersCodexEditPage() { if (loading) return; if (initialData) { - setForm({ + const nextForm: ProviderFormState = { ...initialData, headers: headersToEntries(initialData.headers), modelEntries: modelsToEntries(initialData.models), excludedText: excludedModelsToText(initialData.excludedModels), - }); + }; + setForm(nextForm); + setBaselineSignature(buildCodexSignature(nextForm)); return; } - setForm(buildEmptyForm()); + const nextForm = buildEmptyForm(); + setForm(nextForm); + setBaselineSignature(buildCodexSignature(nextForm)); }, [initialData, loading]); + const currentSignature = useMemo(() => buildCodexSignature(form), [form]); + const isDirty = baselineSignature !== currentSignature; + const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex; + + useUnsavedChangesGuard({ + enabled: canGuard, + shouldBlock: ({ currentLocation, nextLocation }) => + isDirty && currentLocation.pathname !== nextLocation.pathname, + dialog: { + title: t('common.unsaved_changes_title'), + message: t('common.unsaved_changes_message'), + confirmText: t('common.leave'), + cancelText: t('common.stay'), + variant: 'danger', + }, + }); + const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex; const discoveredModelsFiltered = useMemo(() => { diff --git a/src/pages/AiProvidersGeminiEditPage.tsx b/src/pages/AiProvidersGeminiEditPage.tsx index 9bab092..3b77294 100644 --- a/src/pages/AiProvidersGeminiEditPage.tsx +++ b/src/pages/AiProvidersGeminiEditPage.tsx @@ -8,11 +8,12 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList'; import { ModelInputList } from '@/components/ui/ModelInputList'; import { Modal } from '@/components/ui/Modal'; import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack'; +import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard'; import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell'; import { modelsApi, providersApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import type { GeminiKeyConfig } from '@/types'; -import { buildHeaderObject, headersToEntries } from '@/utils/headers'; +import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers'; import type { ModelInfo } from '@/utils/models'; import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils'; import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils'; @@ -44,6 +45,44 @@ const stripGeminiModelResourceName = (value: string) => { return String(value ?? '').trim().replace(/^\/?models\//i, ''); }; +const normalizeHeaderEntries = (entries: HeaderEntry[]) => + (entries ?? []) + .map((entry) => ({ + key: String(entry?.key ?? '').trim(), + value: String(entry?.value ?? '').trim(), + })) + .filter((entry) => entry.key || entry.value) + .sort((a, b) => { + const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase()); + if (byKey !== 0) return byKey; + return a.value.localeCompare(b.value); + }); + +const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) => + (entries ?? []).reduce>((acc, entry) => { + const name = stripGeminiModelResourceName(entry?.name ?? '').trim(); + let alias = String(entry?.alias ?? '').trim(); + if (name && alias === name) { + alias = ''; + } + if (!name && !alias) return acc; + acc.push({ name, alias }); + return acc; + }, []); + +const buildGeminiSignature = (form: GeminiFormState) => + JSON.stringify({ + apiKey: String(form.apiKey ?? '').trim(), + priority: + form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null, + prefix: String(form.prefix ?? '').trim(), + baseUrl: String(form.baseUrl ?? '').trim(), + proxyUrl: String(form.proxyUrl ?? '').trim(), + headers: normalizeHeaderEntries(form.headers), + models: normalizeModelEntries(form.modelEntries), + excludedModels: parseExcludedModels(form.excludedText ?? ''), + }); + export function AiProvidersGeminiEditPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -63,6 +102,7 @@ export function AiProvidersGeminiEditPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [form, setForm] = useState(() => buildEmptyForm()); + const [baselineSignature, setBaselineSignature] = useState(() => buildGeminiSignature(buildEmptyForm())); const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false); const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState(''); @@ -138,7 +178,7 @@ export function AiProvidersGeminiEditPage() { if (initialData) { const { headers, models, ...rest } = initialData; - setForm({ + const nextForm: GeminiFormState = { ...rest, headers: headersToEntries(headers), modelEntries: modelsToEntries(models).map((entry) => ({ @@ -146,10 +186,14 @@ export function AiProvidersGeminiEditPage() { name: stripGeminiModelResourceName(entry.name), })), excludedText: excludedModelsToText(initialData.excludedModels), - }); + }; + setForm(nextForm); + setBaselineSignature(buildGeminiSignature(nextForm)); return; } - setForm(buildEmptyForm()); + const nextForm = buildEmptyForm(); + setForm(nextForm); + setBaselineSignature(buildGeminiSignature(nextForm)); }, [initialData, loading]); const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex; @@ -287,6 +331,23 @@ export function AiProvidersGeminiEditPage() { setModelDiscoveryOpen(false); }; + const currentSignature = useMemo(() => buildGeminiSignature(form), [form]); + const isDirty = baselineSignature !== currentSignature; + const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex; + + useUnsavedChangesGuard({ + enabled: canGuard, + shouldBlock: ({ currentLocation, nextLocation }) => + isDirty && currentLocation.pathname !== nextLocation.pathname, + dialog: { + title: t('common.unsaved_changes_title'), + message: t('common.unsaved_changes_message'), + confirmText: t('common.leave'), + cancelText: t('common.stay'), + variant: 'danger', + }, + }); + const handleSave = useCallback(async () => { if (!canSave) return; diff --git a/src/pages/AiProvidersOpenAIEditLayout.tsx b/src/pages/AiProvidersOpenAIEditLayout.tsx index 4f9b8f7..aaa46b1 100644 --- a/src/pages/AiProvidersOpenAIEditLayout.tsx +++ b/src/pages/AiProvidersOpenAIEditLayout.tsx @@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard'; import { providersApi } from '@/services/api'; 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'; -import { buildHeaderObject, headersToEntries } from '@/utils/headers'; +import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers'; import { buildApiKeyEntry } from '@/components/providers/utils'; import type { ModelEntry, OpenAIFormState } from '@/components/providers/types'; import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore'; @@ -62,6 +63,72 @@ const getErrorMessage = (err: unknown) => { return ''; }; +const normalizeHeaderEntries = (entries: HeaderEntry[]) => + (entries ?? []) + .map((entry) => ({ + key: String(entry?.key ?? '').trim(), + value: String(entry?.value ?? '').trim(), + })) + .filter((entry) => entry.key || entry.value) + .sort((a, b) => { + const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase()); + if (byKey !== 0) return byKey; + return a.value.localeCompare(b.value); + }); + +const normalizeModelEntries = (entries: ModelEntry[]) => + (entries ?? []).reduce>((acc, entry) => { + const name = String(entry?.name ?? '').trim(); + let alias = String(entry?.alias ?? '').trim(); + if (name && (alias === '' || alias === name)) { + alias = ''; + } + if (!name && !alias) return acc; + acc.push({ name, alias }); + return acc; + }, []); + +const normalizeKeyHeaders = (headers: ApiKeyEntry['headers']) => { + if (!headers || typeof headers !== 'object') return []; + return Object.entries(headers) + .map(([key, value]) => ({ key: String(key ?? '').trim(), value: String(value ?? '').trim() })) + .filter((entry) => entry.key || entry.value) + .sort((a, b) => { + const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase()); + if (byKey !== 0) return byKey; + return a.value.localeCompare(b.value); + }); +}; + +const normalizeApiKeyEntries = (entries: ApiKeyEntry[]) => + (entries ?? []).reduce< + Array<{ + apiKey: string; + proxyUrl: string; + headers: Array<{ key: string; value: string }>; + }> + >((acc, entry) => { + const apiKey = String(entry?.apiKey ?? '').trim(); + const proxyUrl = String(entry?.proxyUrl ?? '').trim(); + const headers = normalizeKeyHeaders(entry?.headers); + if (!apiKey && !proxyUrl && headers.length === 0) return acc; + acc.push({ apiKey, proxyUrl, headers }); + return acc; + }, []); + +const buildOpenAISignature = (form: OpenAIFormState, testModel: string) => + JSON.stringify({ + name: String(form.name ?? '').trim(), + priority: + form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null, + prefix: String(form.prefix ?? '').trim(), + baseUrl: String(form.baseUrl ?? '').trim(), + headers: normalizeHeaderEntries(form.headers), + apiKeyEntries: normalizeApiKeyEntries(form.apiKeyEntries), + models: normalizeModelEntries(form.modelEntries), + testModel: String(testModel ?? '').trim(), + }); + export function AiProvidersOpenAIEditLayout() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -87,6 +154,8 @@ export function AiProvidersOpenAIEditLayout() { () => !isCacheValid('openai-compatibility') ); const [saving, setSaving] = useState(false); + const [baselineDraftKey, setBaselineDraftKey] = useState(''); + const [baselineSignature, setBaselineSignature] = useState(''); const draftKey = useMemo(() => { if (invalidIndexParam) return `openai:invalid:${params.index ?? 'unknown'}`; @@ -171,14 +240,19 @@ export function AiProvidersOpenAIEditLayout() { }, [draftKey, ensureDraft]); const handleBack = useCallback(() => { - clearDraft(draftKey); const state = location.state as LocationState; if (state?.fromAiProviders) { navigate(-1); return; } navigate('/ai-providers', { replace: true }); - }, [clearDraft, draftKey, location.state, navigate]); + }, [location.state, navigate]); + + useEffect(() => { + return () => { + clearDraft(draftKey); + }; + }, [clearDraft, draftKey]); useEffect(() => { let cancelled = false; @@ -374,6 +448,40 @@ export function AiProvidersOpenAIEditLayout() { ]); const resolvedLoading = !draft?.initialized; + const currentSignature = useMemo(() => buildOpenAISignature(form, testModel), [form, testModel]); + + useEffect(() => { + if (resolvedLoading) return; + if (baselineDraftKey === draftKey) return; + setBaselineDraftKey(draftKey); + setBaselineSignature(currentSignature); + }, [baselineDraftKey, currentSignature, draftKey, resolvedLoading]); + + const isDirty = baselineDraftKey === draftKey && baselineSignature !== currentSignature; + const editorRootPath = useMemo(() => { + if (hasIndexParam) { + return `/ai-providers/openai/${params.index ?? ''}`; + } + return '/ai-providers/openai/new'; + }, [hasIndexParam, params.index]); + const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex; + + useUnsavedChangesGuard({ + enabled: canGuard, + shouldBlock: ({ nextLocation }) => { + const nextPath = nextLocation.pathname; + const isWithinRoot = + nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`); + return isDirty && !isWithinRoot; + }, + dialog: { + title: t('common.unsaved_changes_title'), + message: t('common.unsaved_changes_message'), + confirmText: t('common.leave'), + cancelText: t('common.stay'), + variant: 'danger', + }, + }); return ( { return Number.isFinite(parsed) ? parsed : null; }; +const normalizeHeaderEntries = (entries: HeaderEntry[]) => + (entries ?? []) + .map((entry) => ({ + key: String(entry?.key ?? '').trim(), + value: String(entry?.value ?? '').trim(), + })) + .filter((entry) => entry.key || entry.value) + .sort((a, b) => { + const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase()); + if (byKey !== 0) return byKey; + return a.value.localeCompare(b.value); + }); + +const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) => + (entries ?? []).reduce>((acc, entry) => { + const name = String(entry?.name ?? '').trim(); + const alias = String(entry?.alias ?? '').trim(); + if (!name && !alias) return acc; + acc.push({ name, alias }); + return acc; + }, []); + +const buildVertexSignature = (form: VertexFormState) => + JSON.stringify({ + apiKey: String(form.apiKey ?? '').trim(), + prefix: String(form.prefix ?? '').trim(), + baseUrl: String(form.baseUrl ?? '').trim(), + proxyUrl: String(form.proxyUrl ?? '').trim(), + headers: normalizeHeaderEntries(form.headers), + models: normalizeModelEntries(form.modelEntries), + }); + export function AiProvidersVertexEditPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -53,6 +86,7 @@ export function AiProvidersVertexEditPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [form, setForm] = useState(() => buildEmptyForm()); + const [baselineSignature, setBaselineSignature] = useState(() => buildVertexSignature(buildEmptyForm())); const hasIndexParam = typeof params.index === 'string'; const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]); @@ -126,18 +160,39 @@ export function AiProvidersVertexEditPage() { if (loading) return; if (initialData) { - setForm({ + const nextForm: VertexFormState = { ...initialData, headers: headersToEntries(initialData.headers), modelEntries: modelsToEntries(initialData.models), - }); + }; + setForm(nextForm); + setBaselineSignature(buildVertexSignature(nextForm)); return; } - setForm(buildEmptyForm()); + const nextForm = buildEmptyForm(); + setForm(nextForm); + setBaselineSignature(buildVertexSignature(nextForm)); }, [initialData, loading]); const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex; + const currentSignature = useMemo(() => buildVertexSignature(form), [form]); + const isDirty = baselineSignature !== currentSignature; + const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex; + + useUnsavedChangesGuard({ + enabled: canGuard, + shouldBlock: ({ currentLocation, nextLocation }) => + isDirty && currentLocation.pathname !== nextLocation.pathname, + dialog: { + title: t('common.unsaved_changes_title'), + message: t('common.unsaved_changes_message'), + confirmText: t('common.leave'), + cancelText: t('common.stay'), + variant: 'danger', + }, + }); + const handleSave = useCallback(async () => { if (!canSave) return;