Files
leaudit-platform-frontend/app/components/ui/MessageModal.tsx
T
TanWenyan 7c47b11ec7 feat(ui): 添加删除操作延迟确认功能
增强用户体验,防止误删除操作:

1. MessageModal 组件增强
   - 添加 confirmDelay 属性(秒)
   - 确认按钮倒计时功能
   - 倒计时期间禁用确认按钮
   - 按钮显示剩余秒数 (例如: "删除 (4s)")

2. 删除操作应用延迟确认(4秒)
   -  文档类型删除 (document-types._index.tsx)
   -  文档删除和批量删除 (documents.list.tsx)
   -  入口模块删除 (entry-modules._index.tsx)
   -  提示词删除 (prompts._index.tsx)
   -  规则组删除 (rule-groups._index.tsx)

技术实现:
- 使用 useEffect + setInterval 实现倒计时
- 倒计时结束自动清理定时器
- 按钮禁用状态控制(disabled + opacity + cursor)

用户体验提升:
- 防止误操作:4秒思考时间
- 视觉反馈:倒计时提示
- 操作可逆:倒计时期间可取消

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 18:17:52 +08:00

333 lines
8.7 KiB
TypeScript

/**
* 全局提示模态框组件
*
* 用于显示成功、错误、警告和信息提示消息
* 支持自动关闭、手动关闭、自定义图标和操作按钮
*/
import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import messageModalStyles from '~/styles/components/message-modal.css?url';
// 消息类型
export type MessageType = 'success' | 'error' | 'warning' | 'info';
// 组件属性
interface MessageModalProps {
// 是否显示模态框
isOpen: boolean;
// 关闭模态框的回调
onClose: () => void;
// 模态框标题
title?: string;
// 模态框消息内容
message: string;
// 消息类型
type?: MessageType;
// 是否自动关闭
autoClose?: boolean;
// 自动关闭的延迟时间(毫秒)
autoCloseDelay?: number;
// 确认按钮文本
confirmText?: string;
// 确认按钮回调
onConfirm?: () => void;
// 取消按钮文本
cancelText?: string;
// 是否显示关闭按钮
showCloseButton?: boolean;
// 自定义图标
customIcon?: React.ReactNode;
// 自定义内容
children?: React.ReactNode;
// 确认按钮延迟时间(秒)- 用于危险操作(如删除)
confirmDelay?: number;
}
// 默认自动关闭延迟
const DEFAULT_AUTO_CLOSE_DELAY = 3000;
// 导出样式
export function links() {
return [{ rel: 'stylesheet', href: messageModalStyles }];
}
export function MessageModal({
isOpen,
onClose,
title,
message,
type = 'info',
autoClose = false,
autoCloseDelay = DEFAULT_AUTO_CLOSE_DELAY,
confirmText = '确定',
onConfirm,
cancelText = '取消',
showCloseButton = true,
customIcon,
children,
confirmDelay = 0
}: MessageModalProps) {
const [isClosing, setIsClosing] = useState(false);
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
const [remainingSeconds, setRemainingSeconds] = useState(confirmDelay);
// 在客户端渲染时获取 portal 容器
useEffect(() => {
if (typeof document !== 'undefined') {
let element = document.getElementById('message-modal-portal');
if (!element) {
element = document.createElement('div');
element.id = 'message-modal-portal';
document.body.appendChild(element);
}
setPortalElement(element);
}
}, []);
// 处理关闭动画
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
setIsClosing(false);
onClose();
}, 300); // 动画持续时间
}, [onClose]);
// 处理确认
const handleConfirm = useCallback(() => {
if (onConfirm) {
onConfirm();
}
handleClose();
}, [onConfirm, handleClose]);
// 自动关闭
useEffect(() => {
if (isOpen && autoClose) {
const timer = setTimeout(() => {
handleClose();
}, autoCloseDelay);
return () => clearTimeout(timer);
}
}, [isOpen, autoClose, autoCloseDelay, handleClose]);
// 确认延迟倒计时
useEffect(() => {
if (isOpen && confirmDelay > 0) {
setRemainingSeconds(confirmDelay);
const timer = setInterval(() => {
setRemainingSeconds((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}
}, [isOpen, confirmDelay]);
// 关闭按钮键盘交互
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose();
}
}, [handleClose]);
// 渲染图标
const renderIcon = () => {
if (customIcon) {
return customIcon;
}
switch (type) {
case 'success':
return <i className="ri-check-line message-modal-icon success"></i>;
case 'error':
return <i className="ri-close-circle-line message-modal-icon error"></i>;
case 'warning':
return <i className="ri-alert-line message-modal-icon warning"></i>;
case 'info':
default:
return <i className="ri-information-line message-modal-icon info"></i>;
}
};
// 如果模态框未打开,不渲染
if (!isOpen || !portalElement) {
return null;
}
// 使用 Portal 渲染模态框
return createPortal(
<div
className={`message-modal-overlay ${isClosing ? 'closing' : ''}`}
onClick={handleClose}
onKeyDown={handleKeyDown}
tabIndex={0}
role="presentation"
aria-label="关闭对话框"
>
<div
className={`message-modal message-modal-${type} ${isClosing ? 'closing' : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby="message-modal-title"
aria-describedby="message-modal-content"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
tabIndex={-1}
>
{showCloseButton && (
<button
className="message-modal-close"
onClick={handleClose}
aria-label="关闭"
>
<i className="ri-close-line"></i>
</button>
)}
<div className="message-modal-icon-wrapper">
{renderIcon()}
</div>
<div className="message-modal-content">
{title && (
<h3 id="message-modal-title" className="message-modal-title">
{title}
</h3>
)}
<div id="message-modal-content" className="message-modal-message" style={{ whiteSpace: 'pre-line' }}>
{message}
</div>
{children && (
<div className="message-modal-custom-content">
{children}
</div>
)}
</div>
<div className="message-modal-actions">
{onConfirm && (
<>
<button
className="message-modal-button primary"
onClick={handleConfirm}
disabled={remainingSeconds > 0}
style={{
opacity: remainingSeconds > 0 ? 0.5 : 1,
cursor: remainingSeconds > 0 ? 'not-allowed' : 'pointer'
}}
>
{remainingSeconds > 0 ? `${confirmText} (${remainingSeconds}s)` : confirmText}
</button>
{cancelText && (
<button
className="message-modal-button"
onClick={handleClose}
>
{cancelText}
</button>
)}
</>
)}
{!onConfirm && (
<button
className="message-modal-button primary"
onClick={handleClose}
>
{confirmText}
</button>
)}
</div>
</div>
</div>,
portalElement
);
}
// 创建全局消息服务
type ShowMessageOptions = Omit<MessageModalProps, 'isOpen' | 'onClose'>;
class MessageService {
private static instance: MessageService;
private showModal: ((options: ShowMessageOptions) => void) | null = null;
private constructor() {}
static getInstance(): MessageService {
if (!MessageService.instance) {
MessageService.instance = new MessageService();
}
return MessageService.instance;
}
registerShowModal(showFn: (options: ShowMessageOptions) => void): void {
this.showModal = showFn;
}
success(message: string, options?: Partial<ShowMessageOptions>): void {
this.show({ ...options, message, type: 'success' });
}
error(message: string, options?: Partial<ShowMessageOptions>): void {
this.show({ ...options, message, type: 'error' });
}
warning(message: string, options?: Partial<ShowMessageOptions>): void {
this.show({ ...options, message, type: 'warning' });
}
info(message: string, options?: Partial<ShowMessageOptions>): void {
this.show({ ...options, message, type: 'info' });
}
show(options: ShowMessageOptions): void {
if (this.showModal) {
this.showModal(options);
} else {
console.error('MessageService: showModal is not registered');
}
}
}
export const messageService = MessageService.getInstance();
/**
* 全局消息模态框容器
* 用于在应用根组件中挂载消息模态框服务
*/
export function MessageModalProvider({ children }: { children: React.ReactNode }) {
const [messageOptions, setMessageOptions] = useState<ShowMessageOptions | null>(null);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
messageService.registerShowModal((options) => {
setMessageOptions(options);
setIsOpen(true);
});
}, []);
const handleClose = () => {
setIsOpen(false);
};
return (
<>
{children}
{messageOptions && (
<MessageModal
{...messageOptions}
isOpen={isOpen}
onClose={handleClose}
/>
)}
</>
);
}