feat: implement batch move and delete functionality for documents and FAQs
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("知识库不存在")
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<void>("/api/dashboard/knowledge-document/batch_move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function batchDeleteKnowledgeDocuments(ids: number[]) {
|
||||
return request<void>("/api/dashboard/knowledge-document/batch_delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildKnowledgeDocumentIndex(documentId: number) {
|
||||
return request<void>("/api/dashboard/knowledge-retrieve/build", {
|
||||
method: "POST",
|
||||
@@ -1730,6 +1750,20 @@ export function deleteKnowledgeFAQ(id: number) {
|
||||
})
|
||||
}
|
||||
|
||||
export function batchMoveKnowledgeFAQs(payload: BatchMoveKnowledgeContentPayload) {
|
||||
return request<void>("/api/dashboard/knowledge-faq/batch_move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function batchDeleteKnowledgeFAQs(ids: number[]) {
|
||||
return request<void>("/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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "根目录内容",
|
||||
|
||||
Reference in New Issue
Block a user