diff --git a/internal/handlers/dashboard/knowledge_directory_handler.go b/internal/handlers/dashboard/knowledge_directory_handler.go index 842629b..070b647 100644 --- a/internal/handlers/dashboard/knowledge_directory_handler.go +++ b/internal/handlers/dashboard/knowledge_directory_handler.go @@ -84,6 +84,10 @@ func KnowledgeDirectoryPostDelete(ctx *gin.Context) { } func KnowledgeDirectoryPostUpdate_sort(ctx *gin.Context) { + if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeBaseUpdate); err != nil { + httpx.WriteJSON(ctx, err) + return + } var req struct { KnowledgeBaseID int64 `json:"knowledgeBaseId"` ParentID int64 `json:"parentId"` diff --git a/web/app/dashboard/knowledge/_components/knowledge-directory-panel.tsx b/web/app/dashboard/knowledge/_components/knowledge-directory-panel.tsx index 5db13fb..088a4bd 100644 --- a/web/app/dashboard/knowledge/_components/knowledge-directory-panel.tsx +++ b/web/app/dashboard/knowledge/_components/knowledge-directory-panel.tsx @@ -1,5 +1,22 @@ "use client"; +import { + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { ChevronDownIcon, ChevronRightIcon, @@ -11,7 +28,7 @@ import { PlusIcon, Trash2Icon, } from "lucide-react"; -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; import { toast } from "sonner"; @@ -39,10 +56,12 @@ import { deleteKnowledgeDirectory, fetchKnowledgeDirectories, updateKnowledgeDirectory, + updateKnowledgeDirectorySort, type KnowledgeDirectory, } from "@/lib/api/admin"; import { useI18n } from "@/i18n/provider"; import { cn } from "@/lib/utils"; +import { findDirectoryParentId, moveDirectoryWithinParent } from "./knowledge-directory-sort"; type KnowledgeDirectoryPanelProps = { knowledgeBaseId: number; @@ -106,6 +125,7 @@ export function KnowledgeDirectoryPanel({ const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + const [sorting, setSorting] = useState(false); const [dialog, setDialog] = useState({ open: false, id: null, @@ -120,6 +140,17 @@ export function KnowledgeDirectoryPanel({ ], [directories, dialog.id, t], ); + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { distance: 8 }, + }), + useSensor(TouchSensor, { + activationConstraint: { delay: 150, tolerance: 8 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); const loadDirectories = useCallback(async () => { setLoading(true); @@ -268,6 +299,48 @@ export function KnowledgeDirectoryPanel({ } } + async function handleDirectoryDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id || sorting) { + return; + } + const activeId = Number(active.id); + const overId = Number(over.id); + if (!Number.isFinite(activeId) || !Number.isFinite(overId)) { + return; + } + + const activeParentId = findDirectoryParentId(directories, activeId); + const overParentId = findDirectoryParentId(directories, overId); + if (activeParentId === null || overParentId === null || activeParentId !== overParentId) { + return; + } + + const previousDirectories = directories; + const moved = moveDirectoryWithinParent(previousDirectories, activeParentId, activeId, overId); + if (!moved.changed) { + return; + } + + setDirectories(moved.items); + setSorting(true); + try { + await updateKnowledgeDirectorySort({ + knowledgeBaseId, + parentId: moved.parentId, + ids: moved.orderedIds, + }); + toast.success(t("knowledge.directorySortUpdated")); + await loadDirectories(); + onChanged?.(); + } catch (error) { + setDirectories(previousDirectories); + toast.error(error instanceof Error ? error.message : t("knowledge.directorySortUpdateFailed")); + } finally { + setSorting(false); + } + } + return ( <>
onSelectDirectory(0)} /> - {directories.map((item) => ( - - setContextMenuDirectoryId(open ? directoryId : null) - } - onCreate={openCreate} - onEdit={openEdit} - onDelete={(directory) => void handleDelete(directory)} - t={t} - /> - ))} + void handleDirectoryDragEnd(event)} + > + item.id)} + strategy={verticalListSortingStrategy} + > + {directories.map((item) => ( + + setContextMenuDirectoryId(open ? directoryId : null) + } + onCreate={openCreate} + onEdit={openEdit} + onDelete={(directory) => void handleDelete(directory)} + t={t} + /> + ))} + +
0; const active = selectedDirectoryId === item.id || contextMenuDirectoryId === item.id; + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; return ( -
+
onContextMenuOpenChange(item.id, open)}>
onSelect(item.id)} + {...attributes} + {...listeners} >
); diff --git a/web/app/dashboard/knowledge/_components/knowledge-directory-sort.test.mjs b/web/app/dashboard/knowledge/_components/knowledge-directory-sort.test.mjs new file mode 100644 index 0000000..2f49e50 --- /dev/null +++ b/web/app/dashboard/knowledge/_components/knowledge-directory-sort.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import ts from "typescript" +import { readFile } from "node:fs/promises" +import vm from "node:vm" + +function plain(value) { + return JSON.parse(JSON.stringify(value)) +} + +async function loadModule() { + const source = await readFile( + new URL("./knowledge-directory-sort.ts", import.meta.url), + "utf8" + ) + const compiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2017, + module: ts.ModuleKind.CommonJS, + }, + fileName: "knowledge-directory-sort.ts", + }) + const sandbox = { + exports: {}, + module: { exports: {} }, + } + sandbox.exports = sandbox.module.exports + vm.runInNewContext(compiled.outputText, sandbox) + return sandbox.module.exports +} + +const tree = [ + { id: 1, parentId: 0, name: "A", children: [ + { id: 11, parentId: 1, name: "A-1", children: [] }, + { id: 12, parentId: 1, name: "A-2", children: [] }, + ] }, + { id: 2, parentId: 0, name: "B", children: [] }, + { id: 3, parentId: 0, name: "C", children: [] }, +] + +describe("knowledge directory sorting", () => { + it("moves root directories within the same parent", async () => { + const { moveDirectoryWithinParent } = await loadModule() + + const next = moveDirectoryWithinParent(tree, 0, 3, 1) + + assert.deepEqual(plain(next.items).map((item) => item.id), [3, 1, 2]) + assert.equal(next.changed, true) + assert.equal(next.parentId, 0) + assert.deepEqual(plain(next.orderedIds), [3, 1, 2]) + }) + + it("moves child directories within their parent", async () => { + const { moveDirectoryWithinParent } = await loadModule() + + const next = moveDirectoryWithinParent(tree, 1, 12, 11) + + assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12, 11]) + assert.equal(next.changed, true) + assert.equal(next.parentId, 1) + assert.deepEqual(plain(next.orderedIds), [12, 11]) + }) + + it("does not move directories across parents", async () => { + const { findDirectoryParentId, moveDirectoryWithinParent } = await loadModule() + + assert.equal(findDirectoryParentId(tree, 11), 1) + assert.equal(findDirectoryParentId(tree, 2), 0) + + const next = moveDirectoryWithinParent(tree, 1, 11, 2) + + assert.equal(next.changed, false) + assert.equal(next.items, tree) + assert.deepEqual(plain(next.orderedIds), []) + }) +}) diff --git a/web/app/dashboard/knowledge/_components/knowledge-directory-sort.ts b/web/app/dashboard/knowledge/_components/knowledge-directory-sort.ts new file mode 100644 index 0000000..549a136 --- /dev/null +++ b/web/app/dashboard/knowledge/_components/knowledge-directory-sort.ts @@ -0,0 +1,98 @@ +export type SortableKnowledgeDirectory = { + id: number + parentId: number + children?: SortableKnowledgeDirectory[] +} + +export type MoveDirectoryResult = { + items: T[] + changed: boolean + parentId: number + orderedIds: number[] +} + +function arrayMove(items: T[], fromIndex: number, toIndex: number) { + const next = [...items] + const [item] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, item) + return next +} + +export function findDirectoryParentId( + items: T[], + id: number, +): number | null { + for (const item of items) { + if (item.id === id) { + return item.parentId + } + const childParentId = findDirectoryParentId(item.children as T[] | undefined ?? [], id) + if (childParentId !== null) { + return childParentId + } + } + return null +} + +export function moveDirectoryWithinParent( + items: T[], + parentId: number, + activeId: number, + overId: number, +): MoveDirectoryResult { + if (activeId === overId) { + return { items, changed: false, parentId, orderedIds: [] } + } + + if (parentId === 0) { + return moveSiblingList(items, parentId, activeId, overId) + } + + let changed = false + let orderedIds: number[] = [] + const nextItems = items.map((item) => { + if (item.id === parentId) { + const moved = moveSiblingList((item.children as T[] | undefined) ?? [], parentId, activeId, overId) + changed = moved.changed + orderedIds = moved.orderedIds + return { ...item, children: moved.items } + } + if (item.children?.length) { + const moved = moveDirectoryWithinParent(item.children as T[], parentId, activeId, overId) + if (moved.changed) { + changed = true + orderedIds = moved.orderedIds + return { ...item, children: moved.items } + } + } + return item + }) + + return { + items: changed ? nextItems : items, + changed, + parentId, + orderedIds, + } +} + +function moveSiblingList( + items: T[], + parentId: number, + activeId: number, + overId: number, +): MoveDirectoryResult { + const oldIndex = items.findIndex((item) => item.id === activeId) + const newIndex = items.findIndex((item) => item.id === overId) + if (oldIndex < 0 || newIndex < 0) { + return { items, changed: false, parentId, orderedIds: [] } + } + + const nextItems = arrayMove(items, oldIndex, newIndex) + return { + items: nextItems, + changed: true, + parentId, + orderedIds: nextItems.map((item) => item.id), + } +} diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index cc77463..01d67fb 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -1663,6 +1663,17 @@ export function deleteKnowledgeDirectory(id: number) { }) } +export function updateKnowledgeDirectorySort(payload: { + knowledgeBaseId: number + parentId: number + ids: number[] +}) { + return request("/api/dashboard/knowledge-directory/update_sort", { + method: "POST", + body: JSON.stringify(payload), + }) +} + export function fetchKnowledgeDocuments( query?: Record ) { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 22635e2..7a5884e 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -1804,6 +1804,8 @@ "directoryDeleted": "Directory deleted: {name}", "directorySaveFailed": "Could not save the directory.", "directoryDeleteFailed": "Could not delete the directory.", + "directorySortUpdated": "Directory order updated.", + "directorySortUpdateFailed": "Could not update directory order.", "directoryNameRequired": "Directory name is required.", "rebuildStarted": "Knowledge base reindex started: {name}", "rebuildFailed": "Could not rebuild the knowledge base index.", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index ff45f68..16613cd 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1804,6 +1804,8 @@ "directoryDeleted": "已删除目录:{name}", "directorySaveFailed": "保存目录失败", "directoryDeleteFailed": "删除目录失败", + "directorySortUpdated": "目录排序已更新", + "directorySortUpdateFailed": "更新目录排序失败", "directoryNameRequired": "目录名称不能为空", "rebuildStarted": "已开始重建知识库索引:{name}", "rebuildFailed": "重建知识库索引失败",