feat: implement batch move and delete functionality for documents and FAQs

This commit is contained in:
mlogclub
2026-06-02 16:13:08 +08:00
parent 1bdc1cb2b6
commit 842f827c86
12 changed files with 727 additions and 2 deletions
@@ -2,15 +2,18 @@
import {
FileTextIcon,
FolderInputIcon,
MoreHorizontalIcon,
PencilIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { toast } from "sonner";
import { useConfirm } from "@/components/confirm-provider";
import {
useDashboardPagedList,
type DashboardPagedListFilter,
@@ -18,6 +21,7 @@ import {
import { ListPagination } from "@/components/list-pagination";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
@@ -40,6 +44,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
batchDeleteKnowledgeDocuments,
batchMoveKnowledgeDocuments,
buildKnowledgeDocumentIndex,
createKnowledgeDocument,
deleteKnowledgeDocument,
@@ -55,6 +61,7 @@ import {
} from "@/lib/generated/enums";
import { cn, formatDateTime } from "@/lib/utils";
import { DocumentEditDialog } from "./document-edit";
import { KnowledgeBulkMoveDialog } from "./knowledge-bulk-move-dialog";
import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel";
type DocumentListProps = {
@@ -140,7 +147,11 @@ const VIEW_MODE_STORAGE_KEY = "knowledge-document-view-mode";
export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentListProps) {
const t = useI18n();
const confirm = useConfirm();
const [saving, setSaving] = useState(false);
const [moving, setMoving] = useState(false);
const [bulkMoveOpen, setBulkMoveOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null);
const [actionLoadingMap, setActionLoadingMap] = useState<Record<number, { rebuildIndex: boolean; delete: boolean }>>({});
const [dialogOpen, setDialogOpen] = useState(false);
@@ -157,6 +168,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
useEffect(() => {
setSelectedDirectoryId(null);
setSelectedIds([]);
}, [knowledgeBaseId]);
const filters = useMemo<DashboardPagedListFilter[]>(() => [
@@ -207,6 +219,21 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
loadFailed: t("knowledge.loadDocumentsFailed"),
});
const currentPageIds = useMemo(
() => documents.results.map((item) => item.id),
[documents.results],
);
const currentPageSelectedCount = useMemo(
() => currentPageIds.filter((id) => selectedIds.includes(id)).length,
[currentPageIds, selectedIds],
);
const allCurrentPageSelected =
currentPageIds.length > 0 && currentPageSelectedCount === currentPageIds.length;
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => currentPageIds.includes(id)));
}, [currentPageIds]);
useEffect(() => {
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode);
}, [viewMode]);
@@ -231,6 +258,19 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
applyFilters();
}
function toggleSelected(id: number, checked: boolean) {
setSelectedIds((prev) => {
if (checked) {
return prev.includes(id) ? prev : [...prev, id];
}
return prev.filter((currentId) => currentId !== id);
});
}
function toggleCurrentPage(checked: boolean) {
setSelectedIds(checked ? currentPageIds : []);
}
const openCreateDialog = useCallback(() => {
setEditingItem(null);
setDialogOpen(true);
@@ -305,6 +345,54 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
}
}
async function handleBatchDelete() {
if (selectedIds.length === 0) {
return;
}
const confirmed = await confirm({
title: t("knowledge.batchDelete"),
description: t("knowledge.batchDeleteConfirm", { count: selectedIds.length }),
confirmText: t("knowledge.delete"),
variant: "destructive",
});
if (!confirmed) {
return;
}
setSaving(true);
try {
await batchDeleteKnowledgeDocuments(selectedIds);
toast.success(t("knowledge.batchDeleted", { count: selectedIds.length }));
setSelectedIds([]);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.batchDeleteFailed"));
} finally {
setSaving(false);
}
}
async function handleBatchMove(directoryId: number) {
if (!knowledgeBaseId || selectedIds.length === 0) {
return;
}
setMoving(true);
try {
await batchMoveKnowledgeDocuments({
knowledgeBaseId,
directoryId,
ids: selectedIds,
});
toast.success(t("knowledge.batchMoved", { count: selectedIds.length }));
setBulkMoveOpen(false);
setSelectedIds([]);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.batchMoveFailed"));
} finally {
setMoving(false);
}
}
async function handleBuildIndex(item: KnowledgeDocumentListItem) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
try {
@@ -381,9 +469,19 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
</Button>
</div>
</div>
<div className="min-h-0 flex-1">
<div className="relative min-h-0 flex-1">
<ScrollArea className="h-full">
<div className={viewMode === "grid" ? "p-2 space-y-1" : "p-2 space-y-0.5"}>
{documents.results.length > 0 ? (
<label className="mb-1 flex h-8 w-fit cursor-pointer items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent">
<Checkbox
checked={allCurrentPageSelected}
onCheckedChange={(checked) => toggleCurrentPage(Boolean(checked))}
aria-label={t("knowledge.selectCurrentPage")}
/>
<span>{t("knowledge.selectCurrentPage")}</span>
</label>
) : null}
{documents.results.map((item) => (
viewMode === "grid" ? (
<ContextMenu key={item.id}>
@@ -393,6 +491,13 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2">
<Checkbox
checked={selectedIds.includes(item.id)}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
aria-label={t("knowledge.selectItem", { name: item.title })}
className="mt-0.5"
/>
{/* <FileTextIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" /> */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -469,6 +574,12 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
<div
className="flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full"
>
<Checkbox
checked={selectedIds.includes(item.id)}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
aria-label={t("knowledge.selectItem", { name: item.title })}
/>
{/* <FileTextIcon className="size-4 shrink-0 text-muted-foreground" /> */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -545,6 +656,48 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
) : null}
</div>
</ScrollArea>
{selectedIds.length > 0 ? (
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center px-4">
<div className="pointer-events-auto flex items-center gap-2 rounded-md border bg-popover px-3 py-2 text-xs shadow-lg">
<span className="text-muted-foreground">
{t("knowledge.selectedCount", { count: selectedIds.length })}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7"
onClick={() => setBulkMoveOpen(true)}
disabled={saving || moving}
>
<FolderInputIcon className="size-3.5" />
{t("knowledge.move")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-destructive hover:text-destructive"
onClick={() => void handleBatchDelete()}
disabled={saving || moving}
>
<Trash2Icon className="size-3.5" />
{t("knowledge.delete")}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => setSelectedIds([])}
disabled={saving || moving}
aria-label={t("knowledge.clearSelection")}
>
<XIcon className="size-3.5" />
</Button>
</div>
</div>
) : null}
</div>
<div className="border-t px-6 py-3">
<ListPagination
@@ -567,6 +720,14 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
<KnowledgeBulkMoveDialog
open={bulkMoveOpen}
knowledgeBaseId={knowledgeBaseId}
moving={moving}
selectedCount={selectedIds.length}
onOpenChange={setBulkMoveOpen}
onSubmit={(directoryId) => void handleBatchMove(directoryId)}
/>
</>
);
}
@@ -1,15 +1,18 @@
"use client";
import {
FolderInputIcon,
MoreHorizontalIcon,
PencilIcon,
SearchIcon,
Trash2Icon,
WrenchIcon,
XIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { toast } from "sonner";
import { useConfirm } from "@/components/confirm-provider";
import {
useDashboardPagedList,
type DashboardPagedListFilter,
@@ -18,6 +21,7 @@ import { ListPagination } from "@/components/list-pagination";
import { OptionCombobox } from "@/components/option-combobox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
@@ -39,6 +43,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
batchDeleteKnowledgeFAQs,
batchMoveKnowledgeFAQs,
buildKnowledgeFAQIndex,
createKnowledgeFAQ,
deleteKnowledgeFAQ,
@@ -52,6 +58,7 @@ import { useI18n } from "@/i18n/provider";
import { formatDateTime } from "@/lib/utils";
import { FAQEditDialog } from "./faq-edit";
import { FAQImportDialog } from "./faq-import-dialog";
import { KnowledgeBulkMoveDialog } from "./knowledge-bulk-move-dialog";
import { KnowledgeDirectoryPanel } from "./knowledge-directory-panel";
type FAQListProps = {
@@ -129,7 +136,11 @@ export function FAQList({
onActionStateChange,
}: FAQListProps) {
const t = useI18n();
const confirm = useConfirm();
const [saving, setSaving] = useState(false);
const [moving, setMoving] = useState(false);
const [bulkMoveOpen, setBulkMoveOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [importing, setImporting] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedDirectoryId, setSelectedDirectoryId] = useState<number | null>(null);
@@ -140,6 +151,7 @@ export function FAQList({
useEffect(() => {
setSelectedDirectoryId(null);
setSelectedIds([]);
}, [knowledgeBaseId]);
const filters = useMemo<DashboardPagedListFilter[]>(() => [
@@ -184,6 +196,21 @@ export function FAQList({
loadFailed: t("knowledge.loadFAQFailed"),
});
const currentPageIds = useMemo(
() => faqs.results.map((item) => item.id),
[faqs.results],
);
const currentPageSelectedCount = useMemo(
() => currentPageIds.filter((id) => selectedIds.includes(id)).length,
[currentPageIds, selectedIds],
);
const allCurrentPageSelected =
currentPageIds.length > 0 && currentPageSelectedCount === currentPageIds.length;
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => currentPageIds.includes(id)));
}, [currentPageIds]);
const openCreateDialog = useCallback(() => {
setEditingItem(null);
setDialogOpen(true);
@@ -211,6 +238,19 @@ export function FAQList({
applyFilters();
}
function toggleSelected(id: number, checked: boolean) {
setSelectedIds((prev) => {
if (checked) {
return prev.includes(id) ? prev : [...prev, id];
}
return prev.filter((currentId) => currentId !== id);
});
}
function toggleCurrentPage(checked: boolean) {
setSelectedIds(checked ? currentPageIds : []);
}
function openEditDialog(item: KnowledgeFAQ) {
setEditingItem(item);
setDialogOpen(true);
@@ -262,6 +302,54 @@ export function FAQList({
}
}
async function handleBatchDelete() {
if (selectedIds.length === 0) {
return;
}
const confirmed = await confirm({
title: t("knowledge.batchDelete"),
description: t("knowledge.batchDeleteConfirm", { count: selectedIds.length }),
confirmText: t("knowledge.delete"),
variant: "destructive",
});
if (!confirmed) {
return;
}
setSaving(true);
try {
await batchDeleteKnowledgeFAQs(selectedIds);
toast.success(t("knowledge.batchDeleted", { count: selectedIds.length }));
setSelectedIds([]);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.batchDeleteFailed"));
} finally {
setSaving(false);
}
}
async function handleBatchMove(directoryId: number) {
if (!knowledgeBaseId || selectedIds.length === 0) {
return;
}
setMoving(true);
try {
await batchMoveKnowledgeFAQs({
knowledgeBaseId,
directoryId,
ids: selectedIds,
});
toast.success(t("knowledge.batchMoved", { count: selectedIds.length }));
setBulkMoveOpen(false);
setSelectedIds([]);
await loadData();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("knowledge.batchMoveFailed"));
} finally {
setMoving(false);
}
}
async function handleBuildIndex(item: KnowledgeFAQ) {
setActionLoadingMap((prev) => ({ ...prev, [item.id]: { ...prev[item.id], rebuildIndex: true } }));
try {
@@ -327,13 +415,29 @@ export function FAQList({
</Button>
</div>
</div>
<div className="min-h-0 flex-1">
<div className="relative min-h-0 flex-1">
<ScrollArea className="h-full">
<div className="p-2 space-y-0.5">
{faqs.results.length > 0 ? (
<label className="mb-1 flex h-8 w-fit cursor-pointer items-center gap-2 rounded-md px-2 text-xs text-muted-foreground hover:bg-accent">
<Checkbox
checked={allCurrentPageSelected}
onCheckedChange={(checked) => toggleCurrentPage(Boolean(checked))}
aria-label={t("knowledge.selectCurrentPage")}
/>
<span>{t("knowledge.selectCurrentPage")}</span>
</label>
) : null}
{faqs.results.map((item) => (
<ContextMenu key={item.id}>
<ContextMenuTrigger className="w-full">
<div className="flex items-center gap-3 bg-background p-2 transition-colors hover:bg-accent w-full">
<Checkbox
checked={selectedIds.includes(item.id)}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))}
aria-label={t("knowledge.selectItem", { name: item.question })}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-medium">{item.question}</div>
@@ -400,6 +504,48 @@ export function FAQList({
) : null}
</div>
</ScrollArea>
{selectedIds.length > 0 ? (
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center px-4">
<div className="pointer-events-auto flex items-center gap-2 rounded-md border bg-popover px-3 py-2 text-xs shadow-lg">
<span className="text-muted-foreground">
{t("knowledge.selectedCount", { count: selectedIds.length })}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7"
onClick={() => setBulkMoveOpen(true)}
disabled={saving || moving}
>
<FolderInputIcon className="size-3.5" />
{t("knowledge.move")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-destructive hover:text-destructive"
onClick={() => void handleBatchDelete()}
disabled={saving || moving}
>
<Trash2Icon className="size-3.5" />
{t("knowledge.delete")}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => setSelectedIds([])}
disabled={saving || moving}
aria-label={t("knowledge.clearSelection")}
>
<XIcon className="size-3.5" />
</Button>
</div>
</div>
) : null}
</div>
<div className="border-t px-6 py-3">
<ListPagination
@@ -434,6 +580,14 @@ export function FAQList({
await loadData();
}}
/>
<KnowledgeBulkMoveDialog
open={bulkMoveOpen}
knowledgeBaseId={knowledgeBaseId}
moving={moving}
selectedCount={selectedIds.length}
onOpenChange={setBulkMoveOpen}
onSubmit={(directoryId) => void handleBatchMove(directoryId)}
/>
</>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button";
import {
fetchKnowledgeDirectories,
type KnowledgeDirectory,
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
type KnowledgeBulkMoveDialogProps = {
open: boolean;
knowledgeBaseId: number;
moving: boolean;
selectedCount: number;
onOpenChange: (open: boolean) => void;
onSubmit: (directoryId: number) => void;
};
function flattenDirectoryOptions(
directories: KnowledgeDirectory[],
): Array<{ value: string; label: string }> {
const result: Array<{ value: string; label: string }> = [];
for (const item of directories) {
result.push({ value: String(item.id), label: item.name });
for (const child of item.children ?? []) {
result.push({ value: String(child.id), label: `${item.name} / ${child.name}` });
}
}
return result;
}
export function KnowledgeBulkMoveDialog({
open,
knowledgeBaseId,
moving,
selectedCount,
onOpenChange,
onSubmit,
}: KnowledgeBulkMoveDialogProps) {
const t = useI18n();
const [directories, setDirectories] = useState<KnowledgeDirectory[]>([]);
const [targetDirectoryId, setTargetDirectoryId] = useState("0");
useEffect(() => {
if (!open) {
return;
}
setTargetDirectoryId("0");
fetchKnowledgeDirectories(knowledgeBaseId)
.then(setDirectories)
.catch((error) => {
console.error(error);
setDirectories([]);
});
}, [knowledgeBaseId, open]);
const directoryOptions = useMemo(() => [
{ value: "0", label: t("knowledge.rootContent") },
...flattenDirectoryOptions(directories),
], [directories, t]);
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={t("knowledge.batchMove")}
description={t("knowledge.batchMoveDescription", { count: selectedCount })}
size="sm"
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={moving}
>
{t("knowledge.cancel")}
</Button>
<Button
type="button"
onClick={() => onSubmit(Number(targetDirectoryId))}
disabled={moving || selectedCount <= 0}
>
{moving ? t("knowledge.moving") : t("knowledge.move")}
</Button>
</>
}
>
<div className="space-y-2">
<div className="text-sm font-medium">{t("knowledge.targetDirectory")}</div>
<OptionCombobox
value={targetDirectoryId}
onChange={(value) => setTargetDirectoryId(value ?? "0")}
options={directoryOptions}
placeholder={t("knowledge.selectDirectory")}
searchPlaceholder={t("knowledge.searchDirectory")}
emptyText={t("knowledge.emptyDirectory")}
/>
</div>
</ProjectDialog>
);
}