feat(ui): implement global ConfirmationModal to replace native window.confirm

This commit is contained in:
Supra4E8C
2026-01-17 14:59:46 +08:00
parent 0bb34ca74b
commit b8d7b8997c
4 changed files with 132 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom'; import { HashRouter, Route, Routes } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { NotificationContainer } from '@/components/common/NotificationContainer'; import { NotificationContainer } from '@/components/common/NotificationContainer';
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
import { SplashScreen } from '@/components/common/SplashScreen'; import { SplashScreen } from '@/components/common/SplashScreen';
import { MainLayout } from '@/components/layout/MainLayout'; import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute'; import { ProtectedRoute } from '@/router/ProtectedRoute';
@@ -61,6 +62,7 @@ function App() {
return ( return (
<HashRouter> <HashRouter>
<NotificationContainer /> <NotificationContainer />
<ConfirmationModal />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route

View File

@@ -0,0 +1,58 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { useNotificationStore } from '@/stores';
export function ConfirmationModal() {
const { t } = useTranslation();
const confirmation = useNotificationStore((state) => state.confirmation);
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
const { isOpen, isLoading, options } = confirmation;
if (!isOpen || !options) {
return null;
}
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
const handleConfirm = async () => {
try {
setConfirmationLoading(true);
await onConfirm();
hideConfirmation();
} catch (error) {
console.error('Confirmation action failed:', error);
// Optional: show error notification here if needed,
// but usually the calling component handles specific errors.
} finally {
setConfirmationLoading(false);
}
};
const handleCancel = () => {
if (onCancel) {
onCancel();
}
hideConfirmation();
};
return (
<Modal open={isOpen} onClose={handleCancel} title={title}>
<p style={{ margin: '1rem 0' }}>{message}</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
{cancelText || t('common.cancel')}
</Button>
<Button
variant={variant}
onClick={handleConfirm}
loading={isLoading}
>
{confirmText || t('common.confirm')}
</Button>
</div>
</Modal>
);
}

View File

@@ -14,7 +14,7 @@ import styles from './ApiKeysPage.module.scss';
export function ApiKeysPage() { export function ApiKeysPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
@@ -29,7 +29,6 @@ export function ApiKeysPage() {
const [editingIndex, setEditingIndex] = useState<number | null>(null); const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingIndex, setDeletingIndex] = useState<number | null>(null);
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]); const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
@@ -115,21 +114,24 @@ export function ApiKeysPage() {
} }
}; };
const handleDelete = async (index: number) => { const handleDelete = (index: number) => {
if (!window.confirm(t('api_keys.delete_confirm'))) return; showConfirmation({
setDeletingIndex(index); title: t('common.delete'),
try { message: t('api_keys.delete_confirm'),
await apiKeysApi.delete(index); variant: 'danger',
const nextKeys = apiKeys.filter((_, idx) => idx !== index); onConfirm: async () => {
setApiKeys(nextKeys); try {
updateConfigValue('api-keys', nextKeys); await apiKeysApi.delete(index);
clearCache('api-keys'); const nextKeys = apiKeys.filter((_, idx) => idx !== index);
showNotification(t('notification.api_key_deleted'), 'success'); setApiKeys(nextKeys);
} catch (err: any) { updateConfigValue('api-keys', nextKeys);
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error'); clearCache('api-keys');
} finally { showNotification(t('notification.api_key_deleted'), 'success');
setDeletingIndex(null); } catch (err: any) {
} showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
}
}
});
}; };
const actionButtons = ( const actionButtons = (
@@ -181,8 +183,7 @@ export function ApiKeysPage() {
variant="danger" variant="danger"
size="sm" size="sm"
onClick={() => handleDelete(index)} onClick={() => handleDelete(index)}
disabled={disableControls || deletingIndex === index} disabled={disableControls}
loading={deletingIndex === index}
> >
{t('common.delete')} {t('common.delete')}
</Button> </Button>

View File

@@ -8,15 +8,38 @@ import type { Notification, NotificationType } from '@/types';
import { generateId } from '@/utils/helpers'; import { generateId } from '@/utils/helpers';
import { NOTIFICATION_DURATION_MS } from '@/utils/constants'; import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
interface ConfirmationOptions {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'primary' | 'secondary';
onConfirm: () => void | Promise<void>;
onCancel?: () => void;
}
interface NotificationState { interface NotificationState {
notifications: Notification[]; notifications: Notification[];
confirmation: {
isOpen: boolean;
isLoading: boolean;
options: ConfirmationOptions | null;
};
showNotification: (message: string, type?: NotificationType, duration?: number) => void; showNotification: (message: string, type?: NotificationType, duration?: number) => void;
removeNotification: (id: string) => void; removeNotification: (id: string) => void;
clearAll: () => void; clearAll: () => void;
showConfirmation: (options: ConfirmationOptions) => void;
hideConfirmation: () => void;
setConfirmationLoading: (loading: boolean) => void;
} }
export const useNotificationStore = create<NotificationState>((set) => ({ export const useNotificationStore = create<NotificationState>((set) => ({
notifications: [], notifications: [],
confirmation: {
isOpen: false,
isLoading: false,
options: null
},
showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => { showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => {
const id = generateId(); const id = generateId();
@@ -49,5 +72,34 @@ export const useNotificationStore = create<NotificationState>((set) => ({
clearAll: () => { clearAll: () => {
set({ notifications: [] }); set({ notifications: [] });
},
showConfirmation: (options) => {
set({
confirmation: {
isOpen: true,
isLoading: false,
options
}
});
},
hideConfirmation: () => {
set((state) => ({
confirmation: {
...state.confirmation,
isOpen: false,
options: null // Cleanup
}
}));
},
setConfirmationLoading: (loading) => {
set((state) => ({
confirmation: {
...state.confirmation,
isLoading: loading
}
}));
} }
})); }));