feat(ui): 添加 ConfirmDialog 确认对话框组件
- 支持默认和危险两种样式变体 - 支持 ESC 键关闭和点击外部关闭 - 支持 loading 状态和自定义按钮文案 - 支持自定义内容区域
This commit is contained in:
parent
199772a95d
commit
749247affa
175
src/components/ui/ConfirmDialog.tsx
Normal file
175
src/components/ui/ConfirmDialog.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { X, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'default' | 'danger';
|
||||
loading?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && !loading) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose, loading]);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dialogRef.current &&
|
||||
!dialogRef.current.contains(e.target as Node) &&
|
||||
!loading
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose, loading]);
|
||||
|
||||
// 禁止背景滚动
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* 背景遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/50 animate-fade-in" />
|
||||
|
||||
{/* 对话框 */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
'relative z-10 w-full max-w-md mx-4',
|
||||
'bg-[var(--color-bg-primary)] rounded-lg shadow-xl',
|
||||
'animate-pop-up'
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-border-light)]">
|
||||
<div className="flex items-center gap-3">
|
||||
{variant === 'danger' && (
|
||||
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-red-500/10">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="p-1 rounded-md text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="px-5 py-4">
|
||||
{description && (
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-4">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex justify-end gap-3 px-5 py-4 border-t border-[var(--color-border-light)] bg-[var(--color-bg-secondary)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-md hover:bg-[var(--color-bg-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50',
|
||||
variant === 'danger'
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-dark)]'
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
处理中...
|
||||
</span>
|
||||
) : (
|
||||
confirmText
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user