205 lines
6.0 KiB
TypeScript
205 lines
6.0 KiB
TypeScript
import React, {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
|
|
|
|
export type AppDialogVariant = 'info' | 'success' | 'warning' | 'error';
|
|
|
|
interface AlertOptions {
|
|
title?: string;
|
|
variant?: AppDialogVariant;
|
|
confirmLabel?: string;
|
|
}
|
|
|
|
interface ConfirmOptions extends AlertOptions {
|
|
cancelLabel?: string;
|
|
}
|
|
|
|
interface DialogRequest {
|
|
mode: 'alert' | 'confirm';
|
|
message: string;
|
|
title: string;
|
|
variant: AppDialogVariant;
|
|
confirmLabel: string;
|
|
cancelLabel: string;
|
|
resolve: (value: boolean) => void;
|
|
}
|
|
|
|
interface AppDialogContextValue {
|
|
showAlert: (message: string, options?: AlertOptions) => Promise<void>;
|
|
showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>;
|
|
}
|
|
|
|
const AppDialogContext = createContext<AppDialogContextValue | null>(null);
|
|
|
|
const VARIANT_META: Record<
|
|
AppDialogVariant,
|
|
{ icon: React.ReactNode; title: string; buttonClass: string }
|
|
> = {
|
|
info: {
|
|
icon: <Info className="w-5 h-5 text-indigo-500" />,
|
|
title: '提示',
|
|
buttonClass: 'bg-indigo-600 hover:bg-indigo-700 text-white',
|
|
},
|
|
success: {
|
|
icon: <CheckCircle2 className="w-5 h-5 text-emerald-500" />,
|
|
title: '成功',
|
|
buttonClass: 'bg-emerald-600 hover:bg-emerald-700 text-white',
|
|
},
|
|
warning: {
|
|
icon: <AlertCircle className="w-5 h-5 text-amber-500" />,
|
|
title: '请注意',
|
|
buttonClass: 'bg-amber-500 hover:bg-amber-600 text-white',
|
|
},
|
|
error: {
|
|
icon: <AlertCircle className="w-5 h-5 text-rose-500" />,
|
|
title: '出错了',
|
|
buttonClass: 'bg-rose-600 hover:bg-rose-700 text-white',
|
|
},
|
|
};
|
|
|
|
function AppDialogModal({
|
|
dialog,
|
|
onClose,
|
|
}: {
|
|
dialog: DialogRequest;
|
|
onClose: (confirmed: boolean) => void;
|
|
}) {
|
|
const confirmBtnRef = useRef<HTMLButtonElement>(null);
|
|
const meta = VARIANT_META[dialog.variant];
|
|
|
|
useEffect(() => {
|
|
confirmBtnRef.current?.focus();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onClose(dialog.mode === 'alert');
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKeyDown);
|
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
}, [dialog.mode, onClose]);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[9500] flex items-center justify-center bg-slate-950/60 backdrop-blur-sm p-4"
|
|
onClick={() => onClose(dialog.mode === 'alert')}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
role="alertdialog"
|
|
aria-modal="true"
|
|
aria-labelledby="app-dialog-title"
|
|
aria-describedby="app-dialog-message"
|
|
>
|
|
<div className="flex items-start gap-3 px-5 py-4 border-b border-gray-100">
|
|
<span className="shrink-0 mt-0.5">{meta.icon}</span>
|
|
<div className="flex-1 min-w-0 pr-2">
|
|
<h3 id="app-dialog-title" className="text-sm font-bold text-gray-900">
|
|
{dialog.title}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onClose(dialog.mode === 'alert')}
|
|
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition cursor-pointer shrink-0"
|
|
aria-label="关闭"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="px-5 py-4">
|
|
<p id="app-dialog-message" className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">
|
|
{dialog.message}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end gap-2 px-5 py-4 border-t border-gray-100 bg-gray-50/80">
|
|
{dialog.mode === 'confirm' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onClose(false)}
|
|
className="px-4 py-2 border border-gray-200 text-gray-600 hover:bg-white rounded-lg text-sm font-medium cursor-pointer"
|
|
>
|
|
{dialog.cancelLabel}
|
|
</button>
|
|
)}
|
|
<button
|
|
ref={confirmBtnRef}
|
|
type="button"
|
|
onClick={() => onClose(true)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-semibold cursor-pointer ${meta.buttonClass}`}
|
|
>
|
|
{dialog.confirmLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AppDialogProvider({ children }: { children: React.ReactNode }) {
|
|
const [dialog, setDialog] = useState<DialogRequest | null>(null);
|
|
|
|
const showAlert = useCallback((message: string, options?: AlertOptions) => {
|
|
const variant = options?.variant ?? 'info';
|
|
return new Promise<void>((resolve) => {
|
|
setDialog({
|
|
mode: 'alert',
|
|
message,
|
|
title: options?.title ?? VARIANT_META[variant].title,
|
|
variant,
|
|
confirmLabel: options?.confirmLabel ?? '知道了',
|
|
cancelLabel: '',
|
|
resolve: () => resolve(),
|
|
});
|
|
});
|
|
}, []);
|
|
|
|
const showConfirm = useCallback((message: string, options?: ConfirmOptions) => {
|
|
const variant = options?.variant ?? 'warning';
|
|
return new Promise<boolean>((resolve) => {
|
|
setDialog({
|
|
mode: 'confirm',
|
|
message,
|
|
title: options?.title ?? VARIANT_META[variant].title,
|
|
variant,
|
|
confirmLabel: options?.confirmLabel ?? '确定',
|
|
cancelLabel: options?.cancelLabel ?? '取消',
|
|
resolve,
|
|
});
|
|
});
|
|
}, []);
|
|
|
|
const closeDialog = useCallback((confirmed: boolean) => {
|
|
setDialog((current) => {
|
|
current?.resolve(confirmed);
|
|
return null;
|
|
});
|
|
}, []);
|
|
|
|
return (
|
|
<AppDialogContext.Provider value={{ showAlert, showConfirm }}>
|
|
{children}
|
|
{dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />}
|
|
</AppDialogContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useAppDialog() {
|
|
const ctx = useContext(AppDialogContext);
|
|
if (!ctx) {
|
|
throw new Error('useAppDialog must be used within AppDialogProvider');
|
|
}
|
|
return ctx;
|
|
}
|