feat: add KnowledgeConfirmDialog component and integrate delete confirmation dialog in KnowledgePage

This commit is contained in:
duanshuwen
2026-04-19 10:45:42 +08:00
parent 89f8637db2
commit ba1580caba
5 changed files with 174 additions and 9 deletions

View File

@@ -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 (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px]" />
<Dialog.Content
className="fixed left-1/2 top-1/2 z-60 w-[calc(100vw-32px)] max-w-130 -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-[#F4F3EB] p-0 shadow-[0_30px_80px_rgba(15,23,42,0.18)] outline-none dark:bg-[#1f1f22]"
onEscapeKeyDown={(event) => {
if (busy) {
event.preventDefault();
}
}}
onPointerDownOutside={(event) => {
if (busy) {
event.preventDefault();
}
}}
>
<div className="flex items-start justify-between gap-4 border-b border-black/6 px-6 py-5 dark:border-white/6">
<div className="flex min-w-0 items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-red-500/15 bg-red-500/10 text-red-600 dark:border-red-400/20 dark:bg-red-500/10 dark:text-red-300">
{icon}
</div>
<div className="min-w-0">
<Dialog.Title
className="text-[26px] font-normal leading-none tracking-tight text-[#171717] dark:text-[#f3f4f6]"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
>
{title}
</Dialog.Title>
<Dialog.Description className="mt-3 text-[14px] leading-6 text-[#525866] dark:text-gray-400">
{description}
</Dialog.Description>
</div>
</div>
<button
type="button"
className="rounded-full p-1.5 text-[#99A0AE] transition-colors hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-50 dark:hover:text-[#f3f4f6]"
onClick={onClose}
disabled={busy}
aria-label={closeLabel}
>
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current" strokeWidth="1.8">
<path d="M6 6L18 18M18 6L6 18" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-5">
<button
type="button"
className="inline-flex h-10 items-center rounded-full border border-black/10 px-4 text-[13px] font-medium text-[#171717]/80 transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-[#f3f4f6]"
onClick={onClose}
disabled={busy}
>
{cancelLabel}
</button>
<button
type="button"
className="inline-flex h-10 items-center rounded-full bg-red-600 px-4 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-red-500 dark:hover:bg-red-400"
onClick={onConfirm}
disabled={busy}
>
{busy ? confirmingLabel : confirmLabel}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -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';

View File

@@ -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: 'アップロード中...',

View File

@@ -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<string | null>(null);
const [feedback, setFeedback] = useState<KnowledgeFeedback>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<KnowledgeDocRow | null>(null);
const stats = useMemo(() => ({
count: docs.length,
@@ -282,9 +284,18 @@ export default function KnowledgePage() {
}
}
async function handleDelete(doc: KnowledgeDocRow): Promise<void> {
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<void> {
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() {
</KnowledgeFeedbackBanner>
) : null}
<KnowledgeConfirmDialog
open={Boolean(deleteTarget)}
busy={Boolean(deleteTarget && deletingId === deleteTarget.id)}
title={t('knowledge.deleteDialog.title', undefined, 'Delete document?')}
description={t(
'knowledge.deleteDialog.description',
{ name: deleteTarget?.fileName ?? '' },
'This will permanently remove "{name}".',
)}
fileName={deleteTarget?.fileName ?? ''}
cancelLabel={t('knowledge.common.cancel')}
confirmLabel={t('knowledge.deleteDialog.confirm', undefined, 'Delete document')}
confirmingLabel={t('knowledge.status.deleting')}
closeLabel={t('knowledge.deleteDialog.close', undefined, 'Close dialog')}
icon={<TrashIcon className="h-5 w-5" />}
onClose={closeDeleteDialog}
onConfirm={() => {
void confirmDelete();
}}
/>
<div className="flex h-full w-full flex-col p-10 pb-0 pt-12">
<KnowledgePageHeader
title={t('knowledge.title', undefined, 'Knowledge Docs')}

View File

@@ -21,7 +21,6 @@ vi.mock('../src/pages/Knowledge/copy', () => ({
'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');