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>
This commit is contained in:
2025-11-25 18:17:52 +08:00
parent be529d2f2a
commit 7c47b11ec7
7 changed files with 192 additions and 125 deletions
+35 -9
View File
@@ -40,6 +40,8 @@ interface MessageModalProps {
customIcon?: React.ReactNode; customIcon?: React.ReactNode;
// 自定义内容 // 自定义内容
children?: React.ReactNode; children?: React.ReactNode;
// 确认按钮延迟时间(秒)- 用于危险操作(如删除)
confirmDelay?: number;
} }
// 默认自动关闭延迟 // 默认自动关闭延迟
@@ -63,10 +65,12 @@ export function MessageModal({
cancelText = '取消', cancelText = '取消',
showCloseButton = true, showCloseButton = true,
customIcon, customIcon,
children children,
confirmDelay = 0
}: MessageModalProps) { }: MessageModalProps) {
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null); const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
const [remainingSeconds, setRemainingSeconds] = useState(confirmDelay);
// 在客户端渲染时获取 portal 容器 // 在客户端渲染时获取 portal 容器
useEffect(() => { useEffect(() => {
@@ -108,6 +112,23 @@ export function MessageModal({
} }
}, [isOpen, autoClose, autoCloseDelay, handleClose]); }, [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) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -194,15 +215,20 @@ export function MessageModal({
<div className="message-modal-actions"> <div className="message-modal-actions">
{onConfirm && ( {onConfirm && (
<> <>
<button <button
className="message-modal-button primary" className="message-modal-button primary"
onClick={handleConfirm} onClick={handleConfirm}
disabled={remainingSeconds > 0}
style={{
opacity: remainingSeconds > 0 ? 0.5 : 1,
cursor: remainingSeconds > 0 ? 'not-allowed' : 'pointer'
}}
> >
{confirmText} {remainingSeconds > 0 ? `${confirmText} (${remainingSeconds}s)` : confirmText}
</button> </button>
{cancelText && ( {cancelText && (
<button <button
className="message-modal-button" className="message-modal-button"
onClick={handleClose} onClick={handleClose}
> >
{cancelText} {cancelText}
@@ -210,10 +236,10 @@ export function MessageModal({
)} )}
</> </>
)} )}
{!onConfirm && ( {!onConfirm && (
<button <button
className="message-modal-button primary" className="message-modal-button primary"
onClick={handleClose} onClick={handleClose}
> >
{confirmText} {confirmText}
+1 -1
View File
@@ -25,7 +25,7 @@ export { UploadArea } from './UploadArea';
// 反馈组件 // 反馈组件
export { Alert } from './Alert'; export { Alert } from './Alert';
export { MessageModal } from './MessageModal'; export { MessageModal, messageService } from './MessageModal';
export { LoadingBar } from './LoadingBar'; export { LoadingBar } from './LoadingBar';
export { RouteChangeLoader } from './RouteChangeLoader'; export { RouteChangeLoader } from './RouteChangeLoader';
export { FileProgress } from './FileProgress'; export { FileProgress } from './FileProgress';
+35 -26
View File
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/Button";
import { Pagination } from "~/components/ui/Pagination"; import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel"; import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { toastService } from "~/components/ui/Toast"; import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
import { import {
getDocumentTypes, getDocumentTypes,
deleteDocumentType, deleteDocumentType,
@@ -201,34 +202,42 @@ export default function DocumentTypesList() {
// 处理删除文档类型 // 处理删除文档类型
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (confirm('确定要删除该文档类型吗?此操作不会影响关联的评查点分组,但可能会影响使用该类型的文档评查。')) { messageService.show({
setIsDeleting(true); title: "确认删除",
message: "确定要删除该文档类型吗?此操作不会影响关联的评查点分组,但可能会影响使用该类型的文档评查。",
try { type: "warning",
const formData = new FormData(); confirmText: "删除",
formData.append('id', id.toString()); cancelText: "取消",
formData.append('intent', 'delete'); confirmDelay: 4,
onConfirm: async () => {
const response = await fetch('/document-types?index', { setIsDeleting(true);
method: 'POST',
body: formData try {
}); const formData = new FormData();
formData.append('id', id.toString());
const result = await response.json(); formData.append('intent', 'delete');
if (result.success) { const response = await fetch('/document-types?index', {
alert('删除成功!'); method: 'POST',
// 刷新页面 body: formData
window.location.reload(); });
} else {
alert(`删除失败: ${result.error || '未知错误'}`); const result = await response.json();
if (result.success) {
toastService.success('删除成功!');
// 刷新页面
window.location.reload();
} else {
toastService.error(`删除失败: ${result.error || '未知错误'}`);
}
} catch (error) {
toastService.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
} }
} catch (error) {
alert(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
} }
} });
}; };
// 处理编辑文档类型 // 处理编辑文档类型
+13 -11
View File
@@ -576,21 +576,22 @@ export default function DocumentsIndex() {
toastService.warning("文件正在处理中,无法删除"); toastService.warning("文件正在处理中,无法删除");
return; return;
} }
messageService.show({ messageService.show({
title: "确认删除", title: "确认删除",
message: `确定要删除文档"${name}"吗?`, message: `确定要删除文档"${name}"吗?`,
type: "warning", type: "warning",
confirmText: "删除", confirmText: "删除",
cancelText: "取消", cancelText: "取消",
confirmDelay: 4,
onConfirm: () => { onConfirm: () => {
// 设置删除状态为true // 设置删除状态为true
setIsDeleting(true); setIsDeleting(true);
const form = new FormData(); const form = new FormData();
form.append("_action", "delete"); form.append("_action", "delete");
form.append("id", id); form.append("id", id);
fetcher.submit(form, { method: "post" }); fetcher.submit(form, { method: "post" });
} }
}); });
@@ -602,38 +603,39 @@ export default function DocumentsIndex() {
toastService.error('请至少选择一个文档'); toastService.error('请至少选择一个文档');
return; return;
} }
// 检查是否有正在处理中的文件 // 检查是否有正在处理中的文件
const hasProcessingFiles = documents.some((doc: DocumentUI) => const hasProcessingFiles = documents.some((doc: DocumentUI) =>
selectedRowKeys.includes(doc.id.toString()) && doc.fileStatus !== 'Processed' selectedRowKeys.includes(doc.id.toString()) && doc.fileStatus !== 'Processed'
); );
if (hasProcessingFiles) { if (hasProcessingFiles) {
toastService.error('存在服务器未处理完成的文件,请重新选择需要删除的文件'); toastService.error('存在服务器未处理完成的文件,请重新选择需要删除的文件');
return; return;
} }
messageService.show({ messageService.show({
title: "确认批量删除", title: "确认批量删除",
message: `确认删除选中的 ${selectedRowKeys.length} 个文档?`, message: `确认删除选中的 ${selectedRowKeys.length} 个文档?`,
type: "warning", type: "warning",
confirmText: "删除", confirmText: "删除",
cancelText: "取消", cancelText: "取消",
confirmDelay: 4,
onConfirm: () => { onConfirm: () => {
// 设置删除状态为true // 设置删除状态为true
setIsDeleting(true); setIsDeleting(true);
// 使用fetcher提交表单 // 使用fetcher提交表单
const formData = new FormData(); const formData = new FormData();
formData.append('_action', 'batchDelete'); formData.append('_action', 'batchDelete');
// 添加所有选中的ID // 添加所有选中的ID
selectedRowKeys.forEach(id => { selectedRowKeys.forEach(id => {
formData.append('ids', id); formData.append('ids', id);
}); });
fetcher.submit(formData, { method: 'post' }); fetcher.submit(formData, { method: 'post' });
// 清空选中行 // 清空选中行
setSelectedRowKeys([]); setSelectedRowKeys([]);
} }
+31 -22
View File
@@ -7,6 +7,7 @@ import { Button } from "~/components/ui/Button";
import { Pagination } from "~/components/ui/Pagination"; import { Pagination } from "~/components/ui/Pagination";
import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel"; import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterPanel";
import { toastService } from "~/components/ui/Toast"; import { toastService } from "~/components/ui/Toast";
import { messageService } from "~/components/ui/MessageModal";
import { import {
getEntryModules, getEntryModules,
deleteEntryModule, deleteEntryModule,
@@ -211,34 +212,42 @@ export default function EntryModulesList() {
// 处理删除入口模块 // 处理删除入口模块
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (confirm('确定要删除该入口模块吗?此操作不可撤销。')) { messageService.show({
setIsDeleting(true); title: "确认删除",
message: "确定要删除该入口模块吗?此操作不可撤销。",
type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: async () => {
setIsDeleting(true);
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('id', id.toString()); formData.append('id', id.toString());
formData.append('intent', 'delete'); formData.append('intent', 'delete');
const response = await fetch('/entry-modules', { const response = await fetch('/entry-modules', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
toastService.success('删除成功!'); toastService.success('删除成功!');
// 刷新页面 // 刷新页面
window.location.reload(); window.location.reload();
} else { } else {
toastService.error(`删除失败: ${result.error || '未知错误'}`); toastService.error(`删除失败: ${result.error || '未知错误'}`);
}
} catch (error) {
toastService.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
} }
} catch (error) {
toastService.error(`删除失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setIsDeleting(false);
} }
} });
}; };
// 处理编辑入口模块 // 处理编辑入口模块
+15 -7
View File
@@ -8,7 +8,7 @@ import { FilterPanel, FilterSelect, SearchFilter } from "~/components/ui/FilterP
import { Table } from "~/components/ui/Table"; import { Table } from "~/components/ui/Table";
import { Pagination } from "~/components/ui/Pagination"; import { Pagination } from "~/components/ui/Pagination";
import { getPromptTemplates, deletePromptTemplate, type PromptTemplateUI } from "~/api/prompts/prompts"; import { getPromptTemplates, deletePromptTemplate, type PromptTemplateUI } from "~/api/prompts/prompts";
import { toastService } from "~/components/ui"; import { toastService, messageService } from "~/components/ui";
// 样式链接 // 样式链接
export function links() { export function links() {
@@ -217,13 +217,21 @@ export default function PromptsIndex() {
// 删除模板 // 删除模板
const handleDeleteTemplate = (id: string) => { const handleDeleteTemplate = (id: string) => {
if (confirm('确定要删除该模板吗?删除后无法恢复。')) { messageService.show({
const formData = new FormData(); title: "确认删除",
formData.append('id', id); message: "确定要删除该模板吗?删除后无法恢复。",
formData.append('intent', 'delete'); type: "warning",
confirmText: "删除",
cancelText: "取消",
confirmDelay: 4,
onConfirm: () => {
const formData = new FormData();
formData.append('id', id);
formData.append('intent', 'delete');
fetcher.submit(formData, { method: 'post' }); fetcher.submit(formData, { method: 'post' });
} }
});
}; };
// 监听 fetcher 状态变化 // 监听 fetcher 状态变化
+62 -49
View File
@@ -16,7 +16,7 @@ import {
batchUpdateRuleGroupStatus, batchUpdateRuleGroupStatus,
batchDeleteRuleGroups batchDeleteRuleGroups
} from "~/api/evaluation_points/rule-groups"; } from "~/api/evaluation_points/rule-groups";
import { toastService } from "~/components/ui"; import { toastService, messageService } from "~/components/ui";
export function links() { export function links() {
return [{ rel: "stylesheet", href: indexStyles }]; return [{ rel: "stylesheet", href: indexStyles }];
@@ -230,42 +230,49 @@ export default function RuleGroupsIndex() {
// 处理删除分组 // 处理删除分组
const handleDeleteGroup = async (groupId: string) => { const handleDeleteGroup = async (groupId: string) => {
if (confirm("确定要删除该分组吗?此操作将同时删除该分组下的所有评查点,且不可恢复。")) { messageService.show({
try { title: "确认删除",
const result = await deleteRuleGroup(groupId, frontendJWT); message: "确定要删除该分组吗?此操作将同时删除该分组下的所有评查点,且不可恢复。",
if (result.success) { type: "warning",
// 从本地状态中移除被删除的分组 confirmText: "删除",
setGroups(prev => { cancelText: "取消",
// 如果是一级分组,直接过滤掉 confirmDelay: 4,
const filteredGroups = prev.filter(g => g.id !== groupId); onConfirm: async () => {
try {
// 如果是二级分组,需要从父分组的 children 中移除 const result = await deleteRuleGroup(groupId, frontendJWT);
return filteredGroups.map(group => { if (result.success) {
if (group.children) { // 从本地状态中移除被删除的分组
return { setGroups(prev => {
...group, // 如果是一级分组,直接过滤掉
children: group.children.filter(child => child.id !== groupId) const filteredGroups = prev.filter(g => g.id !== groupId);
};
} // 如果是二级分组,需要从父分组的 children 中移除
return group; return filteredGroups.map(group => {
if (group.children) {
return {
...group,
children: group.children.filter(child => child.id !== groupId)
};
}
return group;
});
}); });
});
// 如果被删除的分组当前是展开状态,从展开列表中移除
// 如果被删除的分组当前是展开状态,从展开列表中移除 setExpandedGroups(prev => prev.filter(id => id !== groupId));
setExpandedGroups(prev => prev.filter(id => id !== groupId));
// 显示成功消息
// 显示成功消息 toastService.success('删除成功')
// alert('删除成功'); } else {
toastService.success('删除成功') toastService.error(`删除失败: ${result.error}`);
} else { console.error(`删除失败: ${result.error}`);
toastService.error(`删除失败: ${result.error}`); }
console.error(`删除失败: ${result.error}`); } catch (error) {
console.error('删除分组失败:', error);
toastService.error('删除分组失败,请稍后重试');
} }
} catch (error) {
console.error('删除分组失败:', error);
toastService.error('删除分组失败,请稍后重试');
} }
} });
}; };
// 🆕 批量启用/禁用 // 🆕 批量启用/禁用
@@ -297,22 +304,28 @@ export default function RuleGroupsIndex() {
return; return;
} }
if (!confirm(`确定要删除选中的 ${selectedIds.length} 个分组吗?此操作不可恢复。`)) { messageService.show({
return; title: "确认批量删除",
} message: `确定要删除选中的 ${selectedIds.length} 个分组吗?此操作不可恢复。`,
type: "warning",
try { confirmText: "删除",
const result = await batchDeleteRuleGroups(selectedIds, frontendJWT); cancelText: "取消",
toastService.success(`成功删除 ${result.deleted_count} 个分组`); confirmDelay: 4,
if (result.failed_ids.length > 0) { onConfirm: async () => {
toastService.warning(`${result.failed_ids.length} 个分组删除失败`); try {
const result = await batchDeleteRuleGroups(selectedIds, frontendJWT);
toastService.success(`成功删除 ${result.deleted_count} 个分组`);
if (result.failed_ids.length > 0) {
toastService.warning(`${result.failed_ids.length} 个分组删除失败`);
}
// 刷新页面以重新加载数据
window.location.reload();
} catch (error) {
console.error('批量删除失败:', error);
toastService.error('批量删除失败,请稍后重试');
}
} }
// 刷新页面以重新加载数据 });
window.location.reload();
} catch (error) {
console.error('批量删除失败:', error);
toastService.error('批量删除失败,请稍后重试');
}
}; };
// 🆕 处理全选/取消全选 // 🆕 处理全选/取消全选