init commit
This commit is contained in:
204
src/components/AppDialog.tsx
Normal file
204
src/components/AppDialog.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user