From 842f827c867bdaccd80027e703555c17b4949d49 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 2 Jun 2026 16:13:08 +0800 Subject: [PATCH] feat: implement batch move and delete functionality for documents and FAQs --- internal/bootstrap/routes.go | 4 + .../dashboard/knowledge_document_handler.go | 35 ++++ .../dashboard/knowledge_faq_handler.go | 35 ++++ internal/pkg/dto/request/knowledge_request.go | 20 +++ .../services/knowledge_document_service.go | 83 +++++++++ internal/services/knowledge_faq_service.go | 63 +++++++ .../knowledge/_components/document-list.tsx | 163 +++++++++++++++++- .../knowledge/_components/faq-list.tsx | 156 ++++++++++++++++- .../knowledge-bulk-move-dialog.tsx | 106 ++++++++++++ web/lib/api/admin.ts | 34 ++++ web/messages/en-US.json | 15 ++ web/messages/zh-CN.json | 15 ++ 12 files changed, 727 insertions(+), 2 deletions(-) create mode 100644 web/app/dashboard/knowledge/_components/knowledge-bulk-move-dialog.tsx diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index be5a45e..f58a785 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -263,6 +263,8 @@ func registerDashboardKnowledgeDirectoryRoutes(group *gin.RouterGroup) { func registerDashboardKnowledgeDocumentRoutes(group *gin.RouterGroup) { group.GET("/:id", dashboard.KnowledgeDocumentGetBy) + group.POST("/batch_delete", dashboard.KnowledgeDocumentPostBatch_delete) + group.POST("/batch_move", dashboard.KnowledgeDocumentPostBatch_move) group.POST("/create", dashboard.KnowledgeDocumentPostCreate) group.POST("/delete", dashboard.KnowledgeDocumentPostDelete) group.Any("/list", dashboard.KnowledgeDocumentAnyList) @@ -273,6 +275,8 @@ func registerDashboardKnowledgeFAQRoutes(group *gin.RouterGroup) { group.GET("/import_template", dashboard.KnowledgeFAQGetImport_template) group.GET("/export", dashboard.KnowledgeFAQGetExport) group.GET("/:id", dashboard.KnowledgeFAQGetBy) + group.POST("/batch_delete", dashboard.KnowledgeFAQPostBatch_delete) + group.POST("/batch_move", dashboard.KnowledgeFAQPostBatch_move) group.POST("/create", dashboard.KnowledgeFAQPostCreate) group.POST("/delete", dashboard.KnowledgeFAQPostDelete) group.POST("/import", dashboard.KnowledgeFAQPostImport) diff --git a/internal/handlers/dashboard/knowledge_document_handler.go b/internal/handlers/dashboard/knowledge_document_handler.go index 08c215d..6c704e0 100644 --- a/internal/handlers/dashboard/knowledge_document_handler.go +++ b/internal/handlers/dashboard/knowledge_document_handler.go @@ -144,3 +144,38 @@ func KnowledgeDocumentPostDelete(ctx *gin.Context) { } httpx.WriteJSON(ctx, nil) } + +func KnowledgeDocumentPostBatch_move(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeDocumentUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.BatchMoveKnowledgeDocumentRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeDocumentService.BatchMoveKnowledgeDocuments(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func KnowledgeDocumentPostBatch_delete(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeDocumentDelete); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.BatchDeleteKnowledgeDocumentRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeDocumentService.BatchDeleteKnowledgeDocuments(req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} diff --git a/internal/handlers/dashboard/knowledge_faq_handler.go b/internal/handlers/dashboard/knowledge_faq_handler.go index adb21d3..7e02192 100644 --- a/internal/handlers/dashboard/knowledge_faq_handler.go +++ b/internal/handlers/dashboard/knowledge_faq_handler.go @@ -192,6 +192,41 @@ func KnowledgeFAQPostDelete(ctx *gin.Context) { httpx.WriteJSON(ctx, nil) } +func KnowledgeFAQPostBatch_move(ctx *gin.Context) { + operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQUpdate) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.BatchMoveKnowledgeFAQRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeFAQService.BatchMoveKnowledgeFAQs(req, operator); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + +func KnowledgeFAQPostBatch_delete(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeFAQDelete); err != nil { + httpx.WriteJSON(ctx, err) + return + } + req := request.BatchDeleteKnowledgeFAQRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + if err := services.KnowledgeFAQService.BatchDeleteKnowledgeFAQs(req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, nil) +} + func writeKnowledgeFAQExcelFile(ctx *gin.Context, file *response.KnowledgeFAQExportedFile) { ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Filename)) ctx.Header("Cache-Control", "no-store") diff --git a/internal/pkg/dto/request/knowledge_request.go b/internal/pkg/dto/request/knowledge_request.go index 8420fa6..6e68ee6 100644 --- a/internal/pkg/dto/request/knowledge_request.go +++ b/internal/pkg/dto/request/knowledge_request.go @@ -55,6 +55,16 @@ type UpdateKnowledgeDocumentRequest struct { CreateKnowledgeDocumentRequest } +type BatchMoveKnowledgeDocumentRequest struct { + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + DirectoryID int64 `json:"directoryId"` + IDs []int64 `json:"ids"` +} + +type BatchDeleteKnowledgeDocumentRequest struct { + IDs []int64 `json:"ids"` +} + type CreateKnowledgeFAQRequest struct { KnowledgeBaseID int64 `json:"knowledgeBaseId"` DirectoryID int64 `json:"directoryId"` @@ -69,6 +79,16 @@ type UpdateKnowledgeFAQRequest struct { CreateKnowledgeFAQRequest } +type BatchMoveKnowledgeFAQRequest struct { + KnowledgeBaseID int64 `json:"knowledgeBaseId"` + DirectoryID int64 `json:"directoryId"` + IDs []int64 `json:"ids"` +} + +type BatchDeleteKnowledgeFAQRequest struct { + IDs []int64 `json:"ids"` +} + type KnowledgeFAQImportMode string const ( diff --git a/internal/services/knowledge_document_service.go b/internal/services/knowledge_document_service.go index f39fad2..d3f4040 100644 --- a/internal/services/knowledge_document_service.go +++ b/internal/services/knowledge_document_service.go @@ -183,6 +183,73 @@ func (s *knowledgeDocumentService) DeleteKnowledgeDocument(id int64) error { return rag.Index.RemoveDocumentIndex(context.Background(), id) } +func (s *knowledgeDocumentService) BatchMoveKnowledgeDocuments(req request.BatchMoveKnowledgeDocumentRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.Unauthorized("未登录或登录已过期") + } + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParam("请选择要移动的文档") + } + kb := KnowledgeBaseService.Get(req.KnowledgeBaseID) + if kb == nil { + return errorsx.InvalidParam("知识库不存在") + } + if kb.KnowledgeType == string(enums.KnowledgeBaseTypeFAQ) { + return errorsx.InvalidParam("FAQ知识库不支持文档") + } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return err + } + for _, id := range ids { + current := s.Get(id) + if current == nil || current.Status == enums.StatusDeleted { + return errorsx.InvalidParam("文档不存在") + } + if current.KnowledgeBaseID != req.KnowledgeBaseID { + return errorsx.InvalidParam("只能移动当前知识库下的文档") + } + } + now := time.Now() + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for _, id := range ids { + if err := repositories.KnowledgeDocumentRepository.Updates(ctx.Tx, id, map[string]any{ + "directory_id": req.DirectoryID, + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + for _, id := range ids { + if err := rag.Index.IndexDocumentByID(context.Background(), id); err != nil { + slog.Error("failed to reindex moved knowledge document", "document_id", id, "error", err) + } + } + return nil +} + +func (s *knowledgeDocumentService) BatchDeleteKnowledgeDocuments(req request.BatchDeleteKnowledgeDocumentRequest) error { + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParam("请选择要删除的文档") + } + for _, id := range ids { + if err := s.DeleteKnowledgeDocument(id); err != nil { + return err + } + } + return nil +} + func (s *knowledgeDocumentService) buildKnowledgeDocumentModel(req request.CreateKnowledgeDocumentRequest) (*models.KnowledgeDocument, error) { if strs.IsBlank(string(req.ContentType)) { req.ContentType = enums.KnowledgeDocumentContentTypeHTML @@ -205,3 +272,19 @@ func (s *knowledgeDocumentService) buildKnowledgeDocumentModel(req request.Creat } return item, nil } + +func uniquePositiveIDs(ids []int64) []int64 { + result := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} diff --git a/internal/services/knowledge_faq_service.go b/internal/services/knowledge_faq_service.go index 36e0d4a..407bc05 100644 --- a/internal/services/knowledge_faq_service.go +++ b/internal/services/knowledge_faq_service.go @@ -122,6 +122,69 @@ func (s *knowledgeFAQService) DeleteKnowledgeFAQ(id int64) error { return rag.Index.RemoveFAQIndex(context.Background(), id) } +func (s *knowledgeFAQService) BatchMoveKnowledgeFAQs(req request.BatchMoveKnowledgeFAQRequest, operator *dto.AuthPrincipal) error { + if operator == nil { + return errorsx.Unauthorized("未登录或登录已过期") + } + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParam("请选择要移动的FAQ") + } + if _, err := s.requireFAQKnowledgeBase(req.KnowledgeBaseID); err != nil { + return err + } + if _, err := KnowledgeDirectoryService.RequireUsableDirectory(req.KnowledgeBaseID, req.DirectoryID); err != nil { + return err + } + for _, id := range ids { + current := s.Get(id) + if current == nil { + return errorsx.InvalidParam("FAQ不存在") + } + if current.KnowledgeBaseID != req.KnowledgeBaseID { + return errorsx.InvalidParam("只能移动当前知识库下的FAQ") + } + } + now := time.Now() + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + for _, id := range ids { + if err := repositories.KnowledgeFAQRepository.Updates(ctx.Tx, id, map[string]any{ + "directory_id": req.DirectoryID, + "index_status": enums.KnowledgeDocumentIndexStatusPending, + "indexed_at": nil, + "index_error": "", + "update_user_id": operator.UserID, + "update_user_name": operator.Username, + "updated_at": now, + }); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + for _, id := range ids { + if err := rag.Index.IndexFAQByID(context.Background(), id); err != nil { + slog.Error("failed to reindex moved knowledge faq", "faq_id", id, "error", err) + } + } + return nil +} + +func (s *knowledgeFAQService) BatchDeleteKnowledgeFAQs(req request.BatchDeleteKnowledgeFAQRequest) error { + ids := uniquePositiveIDs(req.IDs) + if len(ids) == 0 { + return errorsx.InvalidParam("请选择要删除的FAQ") + } + for _, id := range ids { + if err := s.DeleteKnowledgeFAQ(id); err != nil { + return err + } + } + return nil +} + func (s *knowledgeFAQService) buildKnowledgeFAQModel(req request.CreateKnowledgeFAQRequest) (*models.KnowledgeFAQ, error) { if req.KnowledgeBaseID <= 0 { return nil, errorsx.InvalidParam("知识库不存在") diff --git a/web/app/dashboard/knowledge/_components/document-list.tsx b/web/app/dashboard/knowledge/_components/document-list.tsx index 2dad0b8..1e4e7c8 100644 --- a/web/app/dashboard/knowledge/_components/document-list.tsx +++ b/web/app/dashboard/knowledge/_components/document-list.tsx @@ -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([]); const [selectedDirectoryId, setSelectedDirectoryId] = useState(null); const [actionLoadingMap, setActionLoadingMap] = useState>({}); const [dialogOpen, setDialogOpen] = useState(false); @@ -157,6 +168,7 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL useEffect(() => { setSelectedDirectoryId(null); + setSelectedIds([]); }, [knowledgeBaseId]); const filters = useMemo(() => [ @@ -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 -
+
+ {documents.results.length > 0 ? ( + + ) : null} {documents.results.map((item) => ( viewMode === "grid" ? ( @@ -393,6 +491,13 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL >
+ event.stopPropagation()} + onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))} + aria-label={t("knowledge.selectItem", { name: item.title })} + className="mt-0.5" + /> {/* */}
@@ -469,6 +574,12 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL
+ event.stopPropagation()} + onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))} + aria-label={t("knowledge.selectItem", { name: item.title })} + /> {/* */}
@@ -545,6 +656,48 @@ export function DocumentList({ knowledgeBaseId, onActionStateChange }: DocumentL ) : null}
+ {selectedIds.length > 0 ? ( +
+
+ + {t("knowledge.selectedCount", { count: selectedIds.length })} + + + + +
+
+ ) : null}
+ void handleBatchMove(directoryId)} + /> ); } diff --git a/web/app/dashboard/knowledge/_components/faq-list.tsx b/web/app/dashboard/knowledge/_components/faq-list.tsx index dc506d9..73e232c 100644 --- a/web/app/dashboard/knowledge/_components/faq-list.tsx +++ b/web/app/dashboard/knowledge/_components/faq-list.tsx @@ -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([]); const [importing, setImporting] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false); const [selectedDirectoryId, setSelectedDirectoryId] = useState(null); @@ -140,6 +151,7 @@ export function FAQList({ useEffect(() => { setSelectedDirectoryId(null); + setSelectedIds([]); }, [knowledgeBaseId]); const filters = useMemo(() => [ @@ -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({
-
+
+ {faqs.results.length > 0 ? ( + + ) : null} {faqs.results.map((item) => (
+ event.stopPropagation()} + onCheckedChange={(checked) => toggleSelected(item.id, Boolean(checked))} + aria-label={t("knowledge.selectItem", { name: item.question })} + />
{item.question}
@@ -400,6 +504,48 @@ export function FAQList({ ) : null}
+ {selectedIds.length > 0 ? ( +
+
+ + {t("knowledge.selectedCount", { count: selectedIds.length })} + + + + +
+
+ ) : null}
+ void handleBatchMove(directoryId)} + /> ); } diff --git a/web/app/dashboard/knowledge/_components/knowledge-bulk-move-dialog.tsx b/web/app/dashboard/knowledge/_components/knowledge-bulk-move-dialog.tsx new file mode 100644 index 0000000..26712b4 --- /dev/null +++ b/web/app/dashboard/knowledge/_components/knowledge-bulk-move-dialog.tsx @@ -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([]); + 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 ( + + + + + } + > +
+
{t("knowledge.targetDirectory")}
+ setTargetDirectoryId(value ?? "0")} + options={directoryOptions} + placeholder={t("knowledge.selectDirectory")} + searchPlaceholder={t("knowledge.searchDirectory")} + emptyText={t("knowledge.emptyDirectory")} + /> +
+
+ ); +} diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 31841b2..cc77463 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -1532,6 +1532,12 @@ export type UpdateKnowledgeFAQPayload = CreateKnowledgeFAQPayload & { id: number } +export type BatchMoveKnowledgeContentPayload = { + knowledgeBaseId: number + directoryId: number + ids: number[] +} + export type KnowledgeDirectory = { id: number knowledgeBaseId: number @@ -1690,6 +1696,20 @@ export function deleteKnowledgeDocument(id: number) { }) } +export function batchMoveKnowledgeDocuments(payload: BatchMoveKnowledgeContentPayload) { + return request("/api/dashboard/knowledge-document/batch_move", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function batchDeleteKnowledgeDocuments(ids: number[]) { + return request("/api/dashboard/knowledge-document/batch_delete", { + method: "POST", + body: JSON.stringify({ ids }), + }) +} + export function buildKnowledgeDocumentIndex(documentId: number) { return request("/api/dashboard/knowledge-retrieve/build", { method: "POST", @@ -1730,6 +1750,20 @@ export function deleteKnowledgeFAQ(id: number) { }) } +export function batchMoveKnowledgeFAQs(payload: BatchMoveKnowledgeContentPayload) { + return request("/api/dashboard/knowledge-faq/batch_move", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function batchDeleteKnowledgeFAQs(ids: number[]) { + return request("/api/dashboard/knowledge-faq/batch_delete", { + method: "POST", + body: JSON.stringify({ ids }), + }) +} + function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) const link = document.createElement("a") diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 5e84b8e..211d35d 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -1798,6 +1798,21 @@ "rebuildIndex": "Rebuild Index", "deleting": "Deleting...", "delete": "Delete", + "move": "Move", + "moving": "Moving...", + "selectedCount": "{count} selected", + "clearSelection": "Clear selection", + "selectCurrentPage": "Select current page", + "selectItem": "Select {name}", + "batchMove": "Move selected", + "batchMoveDescription": "Move {count} selected items to a directory.", + "batchMoved": "{count} items moved.", + "batchMoveFailed": "Could not move selected items.", + "batchDelete": "Delete selected", + "batchDeleteConfirm": "Delete {count} selected items? This cannot be undone.", + "batchDeleted": "{count} items deleted.", + "batchDeleteFailed": "Could not delete selected items.", + "targetDirectory": "Target directory", "directory": "Directory", "allContent": "All content", "rootContent": "Root content", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 48f9bb9..e9933c9 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1798,6 +1798,21 @@ "rebuildIndex": "重建索引", "deleting": "删除中...", "delete": "删除", + "move": "移动", + "moving": "移动中...", + "selectedCount": "已选择 {count} 项", + "clearSelection": "取消选择", + "selectCurrentPage": "选择当前页", + "selectItem": "选择 {name}", + "batchMove": "批量移动", + "batchMoveDescription": "将已选择的 {count} 项移动到指定目录。", + "batchMoved": "已移动 {count} 项", + "batchMoveFailed": "批量移动失败", + "batchDelete": "批量删除", + "batchDeleteConfirm": "确定删除已选择的 {count} 项吗?此操作不可撤销。", + "batchDeleted": "已删除 {count} 项", + "batchDeleteFailed": "批量删除失败", + "targetDirectory": "目标目录", "directory": "目录", "allContent": "全部内容", "rootContent": "根目录内容",