mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-24 16:04:35 +08:00
fix(providers): confirm before discarding unsaved edits in the provider sheet
- BaseProviderForm and AmpcodeForm now snapshot the initial form signature on mount and report dirty/clean state to the parent via a new onDirtyChange callback - ProviderSheet tracks the form's dirty flag and wraps the cancel button plus the new Sheet.confirmClose hook with a confirmation prompt; the prompt is skipped during submit and in detail mode - Sheet gains an optional confirmClose hook so that Escape, overlay click and the close button all route through the same async check before starting the close animation - Add i18n keys (en/zh-CN/zh-TW/ru) for the unsaved-changes prompt
This commit is contained in:
@@ -26,6 +26,12 @@ interface SheetProps {
|
||||
closeDisabled?: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
/**
|
||||
* If provided, called before starting the close animation when the user
|
||||
* triggers a close (Escape, overlay click, or close button). Return false
|
||||
* (or a Promise that resolves to false) to keep the sheet open.
|
||||
*/
|
||||
confirmClose?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 280;
|
||||
@@ -46,6 +52,7 @@ export function Sheet({
|
||||
closeDisabled = false,
|
||||
className,
|
||||
ariaLabel,
|
||||
confirmClose,
|
||||
children,
|
||||
}: PropsWithChildren<SheetProps>) {
|
||||
const { t } = useTranslation();
|
||||
@@ -106,9 +113,17 @@ export function Sheet({
|
||||
};
|
||||
}, [open, isVisible, startClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const handleClose = useCallback(async () => {
|
||||
if (confirmClose) {
|
||||
try {
|
||||
const ok = await confirmClose();
|
||||
if (ok === false) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
startClose(true);
|
||||
}, [startClose]);
|
||||
}, [confirmClose, startClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sheet } from '@/components/ui/Sheet';
|
||||
import { IconLoader2, IconPencil } from '@/components/ui/icons';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { PROVIDER_DESCRIPTORS } from '../descriptors';
|
||||
import type {
|
||||
ProviderBrand,
|
||||
@@ -41,11 +42,48 @@ export function ProviderSheet({
|
||||
onUpdated,
|
||||
}: ProviderSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showConfirmation } = useNotificationStore();
|
||||
const formId = useId();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Reset dirty flag whenever the sheet is closed or the editing target
|
||||
// (brand / resource / mode) changes — the child form will re-mount and
|
||||
// re-report its own dirty state.
|
||||
useEffect(() => {
|
||||
setIsDirty(false);
|
||||
}, [state.brand, state.mode, state.resource?.id, state.open]);
|
||||
|
||||
const handleDirtyChange = useCallback((dirty: boolean) => {
|
||||
setIsDirty(dirty);
|
||||
}, []);
|
||||
|
||||
const descriptor = PROVIDER_DESCRIPTORS[state.brand];
|
||||
const isAmpcode = state.brand === 'ampcode';
|
||||
const isEditingForm = state.mode === 'create' || state.mode === 'edit';
|
||||
|
||||
const confirmDiscardIfDirty = useCallback((): Promise<boolean> => {
|
||||
if (!isEditingForm || !isDirty || submitting) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
showConfirmation({
|
||||
title: t('providersPage.unsavedChanges.title'),
|
||||
message: t('providersPage.unsavedChanges.message'),
|
||||
variant: 'danger',
|
||||
confirmText: t('providersPage.unsavedChanges.discard'),
|
||||
cancelText: t('providersPage.unsavedChanges.keepEditing'),
|
||||
onConfirm: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}, [isDirty, isEditingForm, showConfirmation, submitting, t]);
|
||||
|
||||
const handleCancelClick = useCallback(() => {
|
||||
void confirmDiscardIfDirty().then((ok) => {
|
||||
if (ok) onClose();
|
||||
});
|
||||
}, [confirmDiscardIfDirty, onClose]);
|
||||
|
||||
const titleText =
|
||||
state.mode === 'create'
|
||||
@@ -116,6 +154,7 @@ export function ProviderSheet({
|
||||
mutating={submitting || workbench.mutating}
|
||||
formId={formId}
|
||||
onSubmit={handleAmpcodeSubmit}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -128,6 +167,7 @@ export function ProviderSheet({
|
||||
mutating={submitting || workbench.mutating}
|
||||
formId={formId}
|
||||
onSubmit={state.mode === 'create' ? handleCreate : handleUpdate}
|
||||
onDirtyChange={handleDirtyChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -166,7 +206,7 @@ export function ProviderSheet({
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.footerBtn} ${styles.footerBtnGhost}`}
|
||||
onClick={onClose}
|
||||
onClick={handleCancelClick}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t('providersPage.actions.cancel')}
|
||||
@@ -205,6 +245,7 @@ export function ProviderSheet({
|
||||
})}
|
||||
footer={footer}
|
||||
closeDisabled={submitting}
|
||||
confirmClose={confirmDiscardIfDirty}
|
||||
>
|
||||
{renderBody()}
|
||||
</Sheet>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useState } from 'react';
|
||||
import { useEffect, useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Collapsible } from '@/components/ui/Collapsible';
|
||||
import { IconPlus, IconX } from '@/components/ui/icons';
|
||||
@@ -52,6 +52,7 @@ interface AmpcodeFormProps {
|
||||
mutating: boolean;
|
||||
formId: string;
|
||||
onSubmit: (config: AmpcodeConfig) => Promise<void>;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function AmpcodeForm({
|
||||
@@ -59,13 +60,26 @@ export function AmpcodeForm({
|
||||
mutating,
|
||||
formId,
|
||||
onSubmit,
|
||||
onDirtyChange,
|
||||
}: AmpcodeFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const fid = useId();
|
||||
const initialConfig = (resource?.raw as AmpcodeConfig | undefined) ?? {};
|
||||
const [form, setForm] = useState<AmpcodeFormState>(() => buildState(initialConfig));
|
||||
const [initialFormSignature] = useState<string>(() =>
|
||||
JSON.stringify(buildState(initialConfig))
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => JSON.stringify(form) !== initialFormSignature,
|
||||
[form, initialFormSignature]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { useEffect, useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
@@ -44,6 +44,7 @@ interface BaseProviderFormProps {
|
||||
mutating: boolean;
|
||||
formId: string;
|
||||
onSubmit: (input: ProviderEntryFormInput) => Promise<void>;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const emptyHeader = () => ({ key: '', value: '' });
|
||||
@@ -196,6 +197,7 @@ export function BaseProviderForm({
|
||||
mutating,
|
||||
formId,
|
||||
onSubmit,
|
||||
onDirtyChange,
|
||||
}: BaseProviderFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const descriptor = PROVIDER_DESCRIPTORS[brand];
|
||||
@@ -203,8 +205,20 @@ export function BaseProviderForm({
|
||||
const [form, setForm] = useState<ProviderEntryFormInput>(() =>
|
||||
buildInitialForm(brand, resource, mode)
|
||||
);
|
||||
const [initialFormSignature] = useState<string>(() =>
|
||||
JSON.stringify(buildInitialForm(brand, resource, mode))
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => JSON.stringify(form) !== initialFormSignature,
|
||||
[form, initialFormSignature]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
const fallbackApiKey = useMemo(() => {
|
||||
if (mode !== 'edit' || !resource) return '';
|
||||
if (brand === 'openaiCompatibility') return '';
|
||||
|
||||
@@ -1600,6 +1600,12 @@
|
||||
"disabled": "Disabled",
|
||||
"toggleFailed": "Failed to update status"
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Discard unsaved changes?",
|
||||
"message": "You have unsaved edits in this form. Closing will discard them.",
|
||||
"discard": "Discard changes",
|
||||
"keepEditing": "Keep editing"
|
||||
},
|
||||
"connectivity": {
|
||||
"test": "Test",
|
||||
"testAll": "Test all",
|
||||
|
||||
@@ -1597,6 +1597,12 @@
|
||||
"disabled": "Отключено",
|
||||
"toggleFailed": "Не удалось обновить статус"
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Отменить несохранённые изменения?",
|
||||
"message": "В форме есть несохранённые изменения. При закрытии они будут потеряны.",
|
||||
"discard": "Отменить изменения",
|
||||
"keepEditing": "Продолжить"
|
||||
},
|
||||
"connectivity": {
|
||||
"test": "Проверить",
|
||||
"testAll": "Проверить все",
|
||||
|
||||
@@ -1600,6 +1600,12 @@
|
||||
"disabled": "已停用",
|
||||
"toggleFailed": "更新状态失败"
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "确定丢弃未保存的修改吗?",
|
||||
"message": "当前表单还有未保存的修改,关闭后会被丢弃。",
|
||||
"discard": "丢弃修改",
|
||||
"keepEditing": "继续编辑"
|
||||
},
|
||||
"connectivity": {
|
||||
"test": "测试",
|
||||
"testAll": "全部测试",
|
||||
|
||||
@@ -1626,6 +1626,12 @@
|
||||
"disabled": "已停用",
|
||||
"toggleFailed": "更新狀態失敗"
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "確定捨棄未儲存的變更嗎?",
|
||||
"message": "目前表單還有尚未儲存的變更,關閉後會遺失。",
|
||||
"discard": "捨棄變更",
|
||||
"keepEditing": "繼續編輯"
|
||||
},
|
||||
"connectivity": {
|
||||
"test": "測試",
|
||||
"testAll": "全部測試",
|
||||
|
||||
Reference in New Issue
Block a user