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