claude-code-cchui/src/components/ui/ConfirmDialog.tsx
gaoziman 749247affa feat(ui): 添加 ConfirmDialog 确认对话框组件
- 支持默认和危险两种样式变体
- 支持 ESC 键关闭和点击外部关闭
- 支持 loading 状态和自定义按钮文案
- 支持自定义内容区域
2025-12-19 15:57:31 +08:00

176 lines
5.2 KiB
TypeScript

'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>
);
}