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:
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理编辑文档类型
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理编辑入口模块
|
||||
|
||||
@@ -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 状态变化
|
||||
|
||||
@@ -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('批量删除失败,请稍后重试');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 🆕 处理全选/取消全选
|
||||
|
||||
Reference in New Issue
Block a user