From ba1580cabac6a2381a37666f057afb2d12757874 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Sun, 19 Apr 2026 10:45:42 +0800 Subject: [PATCH] feat: add KnowledgeConfirmDialog component and integrate delete confirmation dialog in KnowledgePage --- .../components/KnowledgeConfirmDialog.tsx | 109 ++++++++++++++++++ src/pages/Knowledge/components/index.ts | 1 + src/pages/Knowledge/copy.ts | 21 +++- src/pages/Knowledge/index.tsx | 40 ++++++- tests/knowledge-page.test.tsx | 12 +- 5 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 src/pages/Knowledge/components/KnowledgeConfirmDialog.tsx diff --git a/src/pages/Knowledge/components/KnowledgeConfirmDialog.tsx b/src/pages/Knowledge/components/KnowledgeConfirmDialog.tsx new file mode 100644 index 0000000..bb9097d --- /dev/null +++ b/src/pages/Knowledge/components/KnowledgeConfirmDialog.tsx @@ -0,0 +1,109 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import type { ReactNode } from 'react'; + +type KnowledgeConfirmDialogProps = { + open: boolean; + busy: boolean; + title: string; + description: string; + fileName: string; + cancelLabel: string; + confirmLabel: string; + confirmingLabel: string; + closeLabel: string; + icon: ReactNode; + onClose: () => void; + onConfirm: () => void; +}; + +export default function KnowledgeConfirmDialog({ + open, + busy, + title, + description, + cancelLabel, + confirmLabel, + confirmingLabel, + closeLabel, + icon, + onClose, + onConfirm, +}: KnowledgeConfirmDialogProps) { + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen && !busy) { + onClose(); + } + } + + return ( + + + + { + if (busy) { + event.preventDefault(); + } + }} + onPointerDownOutside={(event) => { + if (busy) { + event.preventDefault(); + } + }} + > +
+
+
+ {icon} +
+ +
+ + {title} + + + {description} + +
+
+ + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/pages/Knowledge/components/index.ts b/src/pages/Knowledge/components/index.ts index 0fc2c43..60849d2 100644 --- a/src/pages/Knowledge/components/index.ts +++ b/src/pages/Knowledge/components/index.ts @@ -2,5 +2,6 @@ export { default as KnowledgeDocsTable } from './KnowledgeDocsTable'; export type { KnowledgeDocRow } from './KnowledgeDocsTable'; export { default as KnowledgeEmptyState } from './KnowledgeEmptyState'; export { default as KnowledgeFeedbackBanner } from './KnowledgeFeedbackBanner'; +export { default as KnowledgeConfirmDialog } from './KnowledgeConfirmDialog'; export { default as KnowledgePageHeader } from './KnowledgePageHeader'; export { default as KnowledgeToolbar } from './KnowledgeToolbar'; diff --git a/src/pages/Knowledge/copy.ts b/src/pages/Knowledge/copy.ts index af54c70..af11c75 100644 --- a/src/pages/Knowledge/copy.ts +++ b/src/pages/Knowledge/copy.ts @@ -22,7 +22,6 @@ const EN_KNOWLEDGE_MESSAGES: MessageTree = { storageLabel: 'Storage', emptyTitle: 'No documents yet', emptyDescription: 'Upload a file to start managing the local knowledge docs directory.', - deleteConfirm: 'Delete this document?', table: { name: 'Name', size: 'Size', @@ -39,6 +38,12 @@ const EN_KNOWLEDGE_MESSAGES: MessageTree = { cancel: 'Cancel', confirm: 'Upload', }, + deleteDialog: { + title: 'Delete document?', + description: 'This will permanently remove "{name}" from the local knowledge docs directory.', + confirm: 'Delete document', + close: 'Close dialog', + }, status: { loading: 'Loading documents...', uploading: 'Uploading...', @@ -62,7 +67,6 @@ const ZH_KNOWLEDGE_MESSAGES: MessageTree = { storageLabel: '占用空间', emptyTitle: '暂无文档', emptyDescription: '先上传一个文件,开始管理本地知识文档目录。', - deleteConfirm: '确定要删除此文档吗?', table: { name: '名称', size: '大小', @@ -79,6 +83,12 @@ const ZH_KNOWLEDGE_MESSAGES: MessageTree = { cancel: '取消', confirm: '上传', }, + deleteDialog: { + title: '删除文档?', + description: '此操作会将“{name}”从本地知识文档目录中永久移除。', + confirm: '删除文档', + close: '关闭弹窗', + }, status: { loading: '正在加载文档...', uploading: '上传中...', @@ -102,7 +112,6 @@ const JA_KNOWLEDGE_MESSAGES: MessageTree = { storageLabel: 'Storage', emptyTitle: '文書がありません', emptyDescription: 'まずファイルをアップロードして、ローカルの knowledge docs を管理してください。', - deleteConfirm: 'この文書を削除しますか?', table: { name: '名前', size: 'サイズ', @@ -119,6 +128,12 @@ const JA_KNOWLEDGE_MESSAGES: MessageTree = { cancel: 'キャンセル', confirm: 'アップロード', }, + deleteDialog: { + title: '文書を削除しますか?', + description: 'この操作により「{name}」はローカルの knowledge docs ディレクトリから完全に削除されます。', + confirm: '文書を削除', + close: 'ダイアログを閉じる', + }, status: { loading: '文書を読み込み中...', uploading: 'アップロード中...', diff --git a/src/pages/Knowledge/index.tsx b/src/pages/Knowledge/index.tsx index f52c086..7d44476 100644 --- a/src/pages/Knowledge/index.tsx +++ b/src/pages/Knowledge/index.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode, type SVGProps } from 'react'; import { knowledgeDocsApi } from '../../lib/knowledge-docs-api'; import { useKnowledgeCopy } from './copy'; +import KnowledgeConfirmDialog from './components/KnowledgeConfirmDialog'; import KnowledgeDocsTable, { type KnowledgeDocRow } from './components/KnowledgeDocsTable'; import KnowledgeEmptyState from './components/KnowledgeEmptyState'; import KnowledgeFeedbackBanner from './components/KnowledgeFeedbackBanner'; @@ -201,6 +202,7 @@ export default function KnowledgePage() { const [error, setError] = useState(null); const [feedback, setFeedback] = useState(null); const [deletingId, setDeletingId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const stats = useMemo(() => ({ count: docs.length, @@ -282,9 +284,18 @@ export default function KnowledgePage() { } } - async function handleDelete(doc: KnowledgeDocRow): Promise { - const confirmed = window.confirm(t('knowledge.deleteConfirm')); - if (!confirmed) return; + function handleDelete(doc: KnowledgeDocRow): void { + setDeleteTarget(doc); + } + + function closeDeleteDialog(): void { + if (deletingId) return; + setDeleteTarget(null); + } + + async function confirmDelete(): Promise { + const doc = deleteTarget; + if (!doc) return; setDeletingId(doc.id); setError(null); @@ -292,10 +303,12 @@ export default function KnowledgePage() { try { await knowledgeDocsApi.delete(doc.fileName); setDocs((current) => current.filter((item) => item.id !== doc.id)); + setDeleteTarget(null); pushFeedback(t('knowledge.status.deleteSuccess'), 'success'); } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : String(caughtError); setError(message); + setDeleteTarget(null); pushFeedback(t('knowledge.status.failed', { error: message }), 'error'); } finally { setDeletingId(null); @@ -311,6 +324,27 @@ export default function KnowledgePage() { ) : null} + } + onClose={closeDeleteDialog} + onConfirm={() => { + void confirmDelete(); + }} + /> +
({ 'knowledge.storageLabel': 'Storage', 'knowledge.refresh': 'Refresh', 'knowledge.upload': 'Upload', - 'knowledge.uploadHint': 'Knowledge docs only', 'knowledge.status.loading': 'Loading documents...', 'knowledge.status.uploading': 'Uploading...', 'knowledge.status.deleting': 'Deleting...', @@ -29,7 +28,10 @@ vi.mock('../src/pages/Knowledge/copy', () => ({ 'knowledge.status.deleteSuccess': 'Deleted', 'knowledge.emptyTitle': 'No documents yet', 'knowledge.emptyDescription': 'Upload a file', - 'knowledge.deleteConfirm': 'Delete this document?', + 'knowledge.deleteDialog.title': 'Delete document?', + 'knowledge.deleteDialog.confirm': 'Delete document', + 'knowledge.deleteDialog.close': 'Close dialog', + 'knowledge.common.cancel': 'Cancel', 'knowledge.table.name': 'Name', 'knowledge.table.size': 'Size', 'knowledge.table.modifiedAt': 'Modified At', @@ -42,6 +44,10 @@ vi.mock('../src/pages/Knowledge/copy', () => ({ return `Knowledge docs request failed: ${params?.error ?? ''}`; } + if (path === 'knowledge.deleteDialog.description') { + return `This will permanently remove "${params?.name ?? ''}" from the local knowledge docs directory.`; + } + return dictionary[path] ?? fallback ?? path; }, })); @@ -53,7 +59,6 @@ describe('KnowledgePage', () => { apiMocks.list.mockReset(); apiMocks.upload.mockReset(); apiMocks.delete.mockReset(); - vi.stubGlobal('confirm', vi.fn(() => true)); }); it('loads and renders docs from the knowledge docs api', async () => { @@ -87,6 +92,7 @@ describe('KnowledgePage', () => { const deleteButton = await screen.findByRole('button', { name: /delete/i }); fireEvent.click(deleteButton); + fireEvent.click(screen.getByRole('button', { name: 'Delete document' })); await waitFor(() => { expect(apiMocks.delete).toHaveBeenCalledWith('guide.md');