Files
EasyCLIProxyAPI/src/pages/AgentConfigBackupDialog.tsx
T
lzt404 18a9bfbaa9 fix(ui): unify bottom-right notifications and improve dialog interactions
- Move operation feedback into a shared floating stack to prevent layout shifts
- Allow identical errors to reappear after dismissal
- Support keyboard navigation, scrolling, and focus restoration in dialogs
- Add notification interaction regression tests
- Remove code comments throughout the project
2026-09-15 02:39:03 +08:00

98 lines
6.3 KiB
TypeScript

import { MessageNotice } from '../appNotice';
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { ArchiveRestore, LoaderCircle, X } from 'lucide-react';
import { useI18n } from '../i18n';
import './AgentConfigBackupDialog.css';
type FileSummary = { path: string; exists: boolean | null; size: number | null };
type Version = { id: string; createdAt: string | null; fileCount: number; location: string; files: FileSummary[]; restorable: boolean; error: string | null };
type Listing = { versions: Version[] };
type Preview = { revision: string; files: FileSummary[]; differences: { file: string; field: string; before: string; after: string }[] };
export function AgentConfigBackupDialog({ client, onClose, onRestored }: {
client: string; onClose: () => void; onRestored: () => Promise<void>;
}) {
const { t, formatDate } = useI18n();
const dialog = useRef<HTMLDialogElement>(null);
const [listing, setListing] = useState<Listing>({ versions: [] });
const [selected, setSelected] = useState<Version | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [confirmation, setConfirmation] = useState<'restore' | 'delete' | null>(null);
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let disposed = false;
dialog.current?.showModal();
void invoke<Listing>('list_agent_config_backups', { client })
.then((value) => { if (!disposed) setListing(value); })
.catch((cause) => { if (!disposed) setError(String(cause)); })
.finally(() => { if (!disposed) setBusy(false); });
return () => { disposed = true; };
}, [client]);
const choose = async (version: Version) => {
setBusy(true); setError(''); setSelected(version); setPreview(null); setConfirmation(null);
try {
if (version.restorable) setPreview(await invoke<Preview>('preview_agent_config_backup', { client, id: version.id }));
} catch (cause) { setError(String(cause)); }
finally { setBusy(false); }
};
const confirm = async () => {
if (!selected || !confirmation || (confirmation === 'restore' && !preview)) return;
setBusy(true); setError('');
try {
if (confirmation === 'restore') {
await invoke('restore_agent_config_backup', { client, id: selected.id, revision: preview!.revision });
await onRestored(); onClose();
} else {
await invoke('delete_agent_config_backup', { client, id: selected.id });
setSelected(null); setPreview(null); setConfirmation(null);
setListing(await invoke<Listing>('list_agent_config_backups', { client }));
}
} catch (cause) { setError(String(cause)); setPreview(null); setConfirmation(null); }
finally { setBusy(false); }
};
const date = (version: Version) => version.createdAt
? formatDate(version.createdAt, { dateStyle: 'short', timeStyle: 'medium' }) : version.id;
return <dialog className="agent-backup-modal" ref={dialog} aria-labelledby="agent-backup-title" onCancel={(event) => { event.preventDefault(); if (!busy) onClose(); }}>
<header><h2 id="agent-backup-title"><ArchiveRestore size={20} />{t('agents.backup.button')}</h2>
<button type="button" className="secondary-button" onClick={onClose} disabled={busy} aria-label={t('common.cancel')}><X size={18} /></button></header>
<p>{t('agents.backup.description')}</p>
<div className="agent-backup-columns">
<nav aria-label={t('agents.backup.versions')}>
{!listing.versions.length && !busy && <p>{t('agents.backup.empty')}</p>}
{listing.versions.map((version) => <button type="button" key={version.id} disabled={busy || !!confirmation} className={selected?.id === version.id ? 'active' : ''} onClick={() => void choose(version)}>
<strong>{date(version)}</strong>
<small>{t('agents.backup.files', { count: version.fileCount })}</small>
{!version.restorable && <span>{t('agents.backup.invalid')}</span>}
</button>)}
</nav>
<section className="agent-backup-changes" aria-label={t('agents.backup.preview')}>
{busy && <p role="status"><LoaderCircle size={16} className="spin" /> {t('agents.backup.loading')}</p>}
{!selected && !busy && <p>{t('agents.backup.select')}</p>}
{selected && <>
<strong>{t('agents.backup.location')}</strong><p className="agent-backup-path"><code>{selected.location}</code></p>
{selected.error && <p className="agent-inline-message warning">{selected.error}</p>}
<ul className="agent-backup-files">{selected.files.map((file) => <li key={file.path}>
<code>{file.path}</code><small>{file.exists === null ? t('agents.backup.invalid') : file.exists ? `${t('agents.backup.present')} · ${file.size ?? 0} B` : t('agents.backup.missing')}</small>
</li>)}</ul>
{preview && <p>{t(preview.differences.length ? 'agents.backup.overwrite' : 'agents.backup.unchanged')}</p>}
{preview?.differences.filter((diff) => diff.field.startsWith('modelMappings')).map((diff) => <p key={`${diff.file}:${diff.field}`}>{t('agents.backup.mappingRestore')} <code>{diff.field}</code></p>)}
</>}
</section>
</div>
{confirmation && selected && <div role="alert" className="agent-backup-confirmation">
<strong>{t(confirmation === 'restore' ? 'agents.backup.restoreConfirm' : 'agents.backup.deleteConfirm', { version: date(selected) })}</strong>
<p>{t(confirmation === 'restore' ? 'agents.backup.restoreWarning' : 'agents.backup.deleteWarning')}</p>
</div>}
{error && <MessageNotice message={error} onDismiss={() => setError('')} />}
<footer>
<button type="button" className="secondary-button" disabled={busy} onClick={() => confirmation ? setConfirmation(null) : onClose()}>{t('common.cancel')}</button>
{confirmation ? <button type="button" className="danger-button" disabled={busy} onClick={() => void confirm()}>{t(confirmation === 'restore' ? 'agents.backup.confirmRestore' : 'agents.backup.confirmDelete')}</button> : <>
<button type="button" className="danger-button" disabled={busy || !selected} onClick={() => setConfirmation('delete')}>{t('agents.backup.delete')}</button>
<button type="button" className="primary-button" disabled={busy || !preview} onClick={() => setConfirmation('restore')}>{t('agents.backup.restore')}</button>
</>}
</footer>
</dialog>;
}